diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 10a9703a4c7cf8a99843656d43347c7047e49a5a..8487da3d62186d9efe345a225979dcff8836ee00 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,8 +1,9 @@ <script> import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; +import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; import DagGraph from './dag_graph.vue'; import DagAnnotations from './dag_annotations.vue'; import { @@ -27,23 +28,58 @@ export default { GlEmptyState, GlButton, }, - props: { - graphUrl: { - type: String, - required: false, - default: '', + inject: { + dagDocPath: { + default: null, }, emptySvgPath: { - type: String, - required: true, default: '', }, - dagDocPath: { - type: String, - required: true, + pipelineIid: { + default: '', + }, + pipelineProjectPath: { default: '', }, }, + apollo: { + graphData: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getDagVisData, + variables() { + return { + projectPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + const { + stages: { nodes: stages }, + } = data.project.pipeline; + + const unwrappedGroups = stages + .map(({ name, groups: { nodes: groups } }) => { + return groups.map(group => { + return { category: name, ...group }; + }); + }) + .flat(2); + + const nodes = unwrappedGroups.map(group => { + const jobs = group.jobs.nodes.map(({ name, needs }) => { + return { name, needs: needs.nodes.map(need => need.name) }; + }); + + return { ...group, jobs }; + }); + + return nodes; + }, + error() { + this.reportFailure(LOAD_FAILURE); + }, + }, + }, data() { return { annotationsMap: {}, @@ -90,32 +126,20 @@ export default { default: return { text: this.$options.errorTexts[DEFAULT], - vatiant: 'danger', + variant: 'danger', }; } }, + processedData() { + return this.processGraphData(this.graphData); + }, shouldDisplayAnnotations() { return !isEmpty(this.annotationsMap); }, shouldDisplayGraph() { - return Boolean(!this.showFailureAlert && this.graphData); + return Boolean(!this.showFailureAlert && !this.hasNoDependentJobs && this.graphData); }, }, - mounted() { - const { processGraphData, reportFailure } = this; - - if (!this.graphUrl) { - reportFailure(); - return; - } - - axios - .get(this.graphUrl) - .then(response => { - processGraphData(response.data); - }) - .catch(() => reportFailure(LOAD_FAILURE)); - }, methods: { addAnnotationToMap({ uid, source, target }) { this.$set(this.annotationsMap, uid, { source, target }); @@ -124,25 +148,25 @@ export default { let parsed; try { - parsed = parseData(data.stages); + parsed = parseData(data); } catch { this.reportFailure(PARSE_FAILURE); - return; + return {}; } if (parsed.links.length === 1) { this.reportFailure(UNSUPPORTED_DATA); - return; + return {}; } // If there are no links, we don't report failure // as it simply means the user does not use job dependencies if (parsed.links.length === 0) { this.hasNoDependentJobs = true; - return; + return {}; } - this.graphData = parsed; + return parsed; }, hideAlert() { this.showFailureAlert = false; @@ -182,7 +206,7 @@ export default { <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> <dag-graph v-if="shouldDisplayGraph" - :graph-data="graphData" + :graph-data="processedData" @onFailure="reportFailure" @update-annotation="updateAnnotation" /> @@ -209,7 +233,7 @@ export default { </p> </div> </template> - <template #actions> + <template v-if="dagDocPath" #actions> <gl-button :href="dagDocPath" target="__blank" variant="success"> {{ $options.emptyStateTexts.button }} </gl-button> diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js index 3234f80ee912e3a761aa7459ca4caf8e65ddc29d..1ed415688f29f4472b2dc3464952cfda4b9f2e62 100644 --- a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js @@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash'; received from the endpoint into the format the d3 graph expects. Input is of the form: - [stages] - stages: {name, groups} - groups: [{ name, size, jobs }] - name is a group name; in the case that the group has one job, it is - also the job name - size is the number of parallel jobs - jobs: [{ name, needs}] - job name is either the same as the group name or group x/y + [nodes] + nodes: [{category, name, jobs, size}] + category is the stage name + name is a group name; in the case that the group has one job, it is + also the job name + size is the number of parallel jobs + jobs: [{ name, needs}] + job name is either the same as the group name or group x/y + needs: [job-names] + needs is an array of job-name strings Output is of the form: { nodes: [node], links: [link] } @@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash'; link: { source, target, value }, with source & target being node names and value being a constant - We create nodes, create links, and then dedupe the links, so that in the case where + We create nodes in the GraphQL update function, and then here we create the node dictionary, + then create links, and then dedupe the links, so that in the case where job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link from job 1 to job 2 then another from job 2 to job 4. - CREATE NODES - stage.name -> node.category - stage.group.name -> node.name (this is the group name if there are parallel jobs) - stage.group.jobs -> node.jobs - stage.group.size -> node.size - CREATE LINKS - stages.groups.name -> target - stages.groups.needs.each -> source (source is the name of the group, not the parallel job) + nodes.name -> target + nodes.name.needs.each -> source (source is the name of the group, not the parallel job) 10 -> value (constant) */ -export const createNodes = data => { - return data.flatMap(({ groups, name }) => { - return groups.map(group => { - return { ...group, category: name }; - }); - }); -}; - export const createNodeDict = nodes => { return nodes.reduce((acc, node) => { const newNode = { @@ -62,13 +51,6 @@ export const createNodeDict = nodes => { }, {}); }; -export const createNodesStructure = data => { - const nodes = createNodes(data); - const nodeDict = createNodeDict(nodes); - - return { nodes, nodeDict }; -}; - export const makeLinksFromNodes = (nodes, nodeDict) => { const constantLinkValue = 10; // all links are the same weight return nodes @@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) => return !allAncestors.includes(source); }); -export const parseData = data => { - const { nodes, nodeDict } = createNodesStructure(data); +export const parseData = nodes => { + const nodeDict = createNodeDict(nodes); const allLinks = makeLinksFromNodes(nodes, nodeDict); const filteredLinks = filterByAncestors(allLinks, nodeDict); const links = uniqWith(filteredLinks, isEqual); diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..c73b186739edb888f66752884cff1f0d9c24178e --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql @@ -0,0 +1,27 @@ +query getDagVisData($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + stages { + nodes { + name + groups { + nodes { + name + size + jobs { + nodes { + name + needs { + nodes { + name + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 127d24c5473c219bdc9c79519e1b426939840102..e154c3f2422bb9050927a13435eefd7a3724a7a4 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; -import Dag from './components/dag/dag.vue'; +import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; @@ -114,32 +114,6 @@ const createTestDetails = () => { }); }; -const createDagApp = () => { - if (!window.gon?.features?.dagPipelineTab) { - return; - } - - const el = document.querySelector('#js-pipeline-dag-vue'); - const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - Dag, - }, - render(createElement) { - return createElement('dag', { - props: { - graphUrl: pipelineDataPath, - emptySvgPath, - dagDocPath, - }, - }); - }, - }); -}; - export default () => { const { dataset } = document.querySelector('.js-pipeline-details-vue'); const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js new file mode 100644 index 0000000000000000000000000000000000000000..dc03b4572655fac7377575f7a7922d496d86f2f6 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import Dag from './components/dag/dag.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +const createDagApp = () => { + if (!window.gon?.features?.dagPipelineTab) { + return; + } + + const el = document.querySelector('#js-pipeline-dag-vue'); + const { pipelineProjectPath, pipelineIid, emptySvgPath, dagDocPath } = el?.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + Dag, + }, + apolloProvider, + provide: { + pipelineProjectPath, + pipelineIid, + emptySvgPath, + dagDocPath, + }, + render(createElement) { + return createElement('dag', {}); + }, + }); +}; + +export default createDagApp; diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 9199fdb99d63c8a6a432a40ee60e3855651d27e9..4ae06e1e16f6a3bcaceec8389daa3a624009f506 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -81,7 +81,7 @@ - if dag_pipeline_tab_enabled #js-tab-dag.tab-pane - #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } + #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } #js-tab-tests.tab-pane #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js index 0416e6197244a5b7e2a31e59e51337407fa5a858..989f6c17197198e489440c5f4464894d7104c9b9 100644 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -1,8 +1,5 @@ import { mount, shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; import { GlAlert, GlEmptyState } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; import Dag from '~/pipelines/components/dag/dag.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; @@ -11,13 +8,11 @@ import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES, - DEFAULT, PARSE_FAILURE, - LOAD_FAILURE, UNSUPPORTED_DATA, } from '~/pipelines/components/dag//constants'; import { - mockBaseData, + mockParsedGraphQLNodes, tooSmallGraph, unparseableGraph, graphWithoutDependencies, @@ -27,7 +22,6 @@ import { describe('Pipeline DAG graph wrapper', () => { let wrapper; - let mock; const getAlert = () => wrapper.find(GlAlert); const getAllAlerts = () => wrapper.findAll(GlAlert); const getGraph = () => wrapper.find(DagGraph); @@ -35,45 +29,46 @@ describe('Pipeline DAG graph wrapper', () => { const getErrorText = type => wrapper.vm.$options.errorTexts[type]; const getEmptyState = () => wrapper.find(GlEmptyState); - const dataPath = '/root/test/pipelines/90/dag.json'; - - const createComponent = (propsData = {}, method = shallowMount) => { + const createComponent = ({ + graphData = mockParsedGraphQLNodes, + provideOverride = {}, + method = shallowMount, + } = {}) => { if (wrapper?.destroy) { wrapper.destroy(); } wrapper = method(Dag, { - propsData: { + provide: { + pipelineProjectPath: 'root/abc-dag', + pipelineIid: '1', emptySvgPath: '/my-svg', dagDocPath: '/my-doc', - ...propsData, + ...provideOverride, }, data() { return { + graphData, showFailureAlert: false, }; }, }); }; - beforeEach(() => { - mock = new MockAdapter(axios); - }); - afterEach(() => { - mock.restore(); wrapper.destroy(); wrapper = null; }); - describe('when there is no dataUrl', () => { + describe('when a query argument is undefined', () => { beforeEach(() => { - createComponent({ graphUrl: undefined }); + createComponent({ + provideOverride: { pipelineProjectPath: undefined }, + graphData: null, + }); }); - it('shows the DEFAULT alert and not the graph', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(DEFAULT)); + it('does not render the graph', async () => { expect(getGraph().exists()).toBe(false); }); @@ -82,36 +77,12 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('when there is a dataUrl', () => { - describe('but the data fetch fails', () => { + describe('when all query variables are defined', () => { + describe('but the parse fails', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(500); - createComponent({ graphUrl: dataPath }); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); - }); - - it('shows the LOAD_FAILURE alert and not the graph', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(LOAD_FAILURE)); - expect(getGraph().exists()).toBe(false); - }); - - it('does not render the empty state', () => { - expect(getEmptyState().exists()).toBe(false); - }); - }); - - describe('the data fetch succeeds but the parse fails', () => { - beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, unparseableGraph); - createComponent({ graphUrl: dataPath }); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ + graphData: unparseableGraph, + }); }); it('shows the PARSE_FAILURE alert and not the graph', () => { @@ -125,14 +96,9 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('and the data fetch and parse succeeds', () => { + describe('parse succeeds', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, mockBaseData); - createComponent({ graphUrl: dataPath }, mount); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ method: mount }); }); it('shows the graph', () => { @@ -144,14 +110,11 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('the data fetch and parse succeeds, but the resulting graph is too small', () => { + describe('parse succeeds, but the resulting graph is too small', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, tooSmallGraph); - createComponent({ graphUrl: dataPath }); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ + graphData: tooSmallGraph, + }); }); it('shows the UNSUPPORTED_DATA alert and not the graph', () => { @@ -165,14 +128,12 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('the data fetch succeeds but the returned data is empty', () => { + describe('the returned data is empty', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, graphWithoutDependencies); - createComponent({ graphUrl: dataPath }, mount); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ + method: mount, + graphData: graphWithoutDependencies, + }); }); it('does not render an error alert or the graph', () => { @@ -188,12 +149,7 @@ describe('Pipeline DAG graph wrapper', () => { describe('annotations', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, mockBaseData); - createComponent({ graphUrl: dataPath }, mount); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent(); }); it('toggles on link mouseover and mouseout', async () => { diff --git a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js index a50163411ed4f858f994832568bf7378a406a7ba..37a7d07485b2b4e328bdaf217d115201ce2355a2 100644 --- a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js @@ -1,9 +1,9 @@ import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { parseData } from '~/pipelines/components/dag/parsing_utils'; -import { mockBaseData } from './mock_data'; +import { mockParsedGraphQLNodes } from './mock_data'; describe('DAG visualization drawing utilities', () => { - const parsed = parseData(mockBaseData.stages); + const parsed = parseData(mockParsedGraphQLNodes); const layoutSettings = { width: 200, diff --git a/spec/frontend/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js index 3b39b9cd21cfc43554d22133560fc58da1912b40..e7e938041954252479df5d848bcb3a7bf91dc18a 100644 --- a/spec/frontend/pipelines/components/dag/mock_data.js +++ b/spec/frontend/pipelines/components/dag/mock_data.js @@ -1,127 +1,56 @@ -/* - 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 tooSmallGraph = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; -export const graphWithoutDependencies = { - stages: [ - { - name: 'test', - groups: [ - { - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], - }, - { - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec' }], - }, - ], - }, - { - 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 graphWithoutDependencies = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec' }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; export const unparseableGraph = [ { @@ -468,3 +397,264 @@ export const multiNote = { }, }, }; + +/* + It is important that the base include parallel jobs + as well as non-parallel jobs with spaces in the name to prevent + us relying on spaces as an indicator. +*/ + +export const mockParsedGraphQLNodes = [ + { + category: 'build', + name: 'build_a', + size: 1, + jobs: [ + { + name: 'build_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'build', + name: 'build_b', + size: 1, + jobs: [ + { + name: 'build_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_a', + size: 1, + jobs: [ + { + name: 'test_a', + needs: ['build_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_b', + size: 1, + jobs: [ + { + name: 'test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_c', + size: 1, + jobs: [ + { + name: 'test_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_d', + size: 1, + jobs: [ + { + name: 'test_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_a', + size: 1, + jobs: [ + { + name: 'post_test_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_b', + size: 1, + jobs: [ + { + name: 'post_test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_c', + size: 1, + jobs: [ + { + name: 'post_test_c', + needs: ['test_b', 'test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_a', + size: 1, + jobs: [ + { + name: 'staging_a', + needs: ['post_test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_b', + size: 1, + jobs: [ + { + name: 'staging_b', + needs: ['post_test_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_c', + size: 1, + jobs: [ + { + name: 'staging_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_d', + size: 1, + jobs: [ + { + name: 'staging_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_e', + size: 1, + jobs: [ + { + name: 'staging_e', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_a', + size: 1, + jobs: [ + { + name: 'canary_a', + needs: ['staging_b', 'staging_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_b', + size: 1, + jobs: [ + { + name: 'canary_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_c', + size: 1, + jobs: [ + { + name: 'canary_c', + needs: ['staging_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_a', + size: 1, + jobs: [ + { + name: 'production_a', + needs: ['canary_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_b', + size: 1, + jobs: [ + { + name: 'production_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_c', + size: 1, + jobs: [ + { + name: 'production_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_d', + size: 1, + jobs: [ + { + name: 'production_d', + needs: ['canary_c'], + }, + ], + __typename: 'CiGroup', + }, +]; diff --git a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js index d9a1296e5720b1818cef697d656aefdc27a115c7..e93fa8e67600cb877336080e8966290afb67c69c 100644 --- a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js @@ -1,5 +1,5 @@ import { - createNodesStructure, + createNodeDict, makeLinksFromNodes, filterByAncestors, parseData, @@ -8,56 +8,17 @@ import { } from '~/pipelines/components/dag/parsing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { mockBaseData } from './mock_data'; +import { mockParsedGraphQLNodes } 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); - }); - }); + const nodeDict = createNodeDict(mockParsedGraphQLNodes); + const unfilteredLinks = makeLinksFromNodes(mockParsedGraphQLNodes, nodeDict); + const parsed = parseData(mockParsedGraphQLNodes); describe('makeLinksFromNodes', () => { it('returns the expected link structure', () => { - expect(unfilteredLinks[0]).toHaveProperty('source', 'frontend fixtures'); - expect(unfilteredLinks[0]).toHaveProperty('target', 'jest'); + expect(unfilteredLinks[0]).toHaveProperty('source', 'build_a'); + expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); expect(unfilteredLinks[0]).toHaveProperty('value', 10); }); }); @@ -107,8 +68,22 @@ describe('DAG visualization parsing utilities', () => { describe('removeOrphanNodes', () => { it('removes sankey nodes that have no needs and are not needed', () => { + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); const cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); - expect(cleanedNodes).toHaveLength(sankeyLayout.nodes.length - 1); + /* + These lengths are determined by the mock data. + If the data changes, the numbers may also change. + */ + expect(parsed.nodes).toHaveLength(21); + expect(cleanedNodes).toHaveLength(12); }); });