Commit 182f2616 authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo Committed by Natalia Tepluhina

Rework DAG for graphQL

Adds new structural files, add specs,
and revises current query method
parent 86abb0af
<script> <script>
import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; 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 DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue'; import DagAnnotations from './dag_annotations.vue';
import { import {
...@@ -27,22 +28,57 @@ export default { ...@@ -27,22 +28,57 @@ export default {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
}, },
props: { inject: {
graphUrl: { dagDocPath: {
type: String, default: null,
required: false,
default: '',
}, },
emptySvgPath: { emptySvgPath: {
type: String,
required: true,
default: '', default: '',
}, },
dagDocPath: { pipelineIid: {
type: String,
required: true,
default: '', 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() { data() {
return { return {
...@@ -90,31 +126,19 @@ export default { ...@@ -90,31 +126,19 @@ export default {
default: default:
return { return {
text: this.$options.errorTexts[DEFAULT], text: this.$options.errorTexts[DEFAULT],
vatiant: 'danger', variant: 'danger',
}; };
} }
}, },
processedData() {
return this.processGraphData(this.graphData);
},
shouldDisplayAnnotations() { shouldDisplayAnnotations() {
return !isEmpty(this.annotationsMap); return !isEmpty(this.annotationsMap);
}, },
shouldDisplayGraph() { 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: { methods: {
addAnnotationToMap({ uid, source, target }) { addAnnotationToMap({ uid, source, target }) {
...@@ -124,25 +148,25 @@ export default { ...@@ -124,25 +148,25 @@ export default {
let parsed; let parsed;
try { try {
parsed = parseData(data.stages); parsed = parseData(data);
} catch { } catch {
this.reportFailure(PARSE_FAILURE); this.reportFailure(PARSE_FAILURE);
return; return {};
} }
if (parsed.links.length === 1) { if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA); this.reportFailure(UNSUPPORTED_DATA);
return; return {};
} }
// If there are no links, we don't report failure // If there are no links, we don't report failure
// as it simply means the user does not use job dependencies // as it simply means the user does not use job dependencies
if (parsed.links.length === 0) { if (parsed.links.length === 0) {
this.hasNoDependentJobs = true; this.hasNoDependentJobs = true;
return; return {};
} }
this.graphData = parsed; return parsed;
}, },
hideAlert() { hideAlert() {
this.showFailureAlert = false; this.showFailureAlert = false;
...@@ -182,7 +206,7 @@ export default { ...@@ -182,7 +206,7 @@ export default {
<dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
<dag-graph <dag-graph
v-if="shouldDisplayGraph" v-if="shouldDisplayGraph"
:graph-data="graphData" :graph-data="processedData"
@onFailure="reportFailure" @onFailure="reportFailure"
@update-annotation="updateAnnotation" @update-annotation="updateAnnotation"
/> />
...@@ -209,7 +233,7 @@ export default { ...@@ -209,7 +233,7 @@ export default {
</p> </p>
</div> </div>
</template> </template>
<template #actions> <template v-if="dagDocPath" #actions>
<gl-button :href="dagDocPath" target="__blank" variant="success"> <gl-button :href="dagDocPath" target="__blank" variant="success">
{{ $options.emptyStateTexts.button }} {{ $options.emptyStateTexts.button }}
</gl-button> </gl-button>
......
...@@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash'; ...@@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash';
received from the endpoint into the format the d3 graph expects. received from the endpoint into the format the d3 graph expects.
Input is of the form: Input is of the form:
[stages] [nodes]
stages: {name, groups} nodes: [{category, name, jobs, size}]
groups: [{ name, size, jobs }] category is the stage name
name is a group name; in the case that the group has one job, it is name is a group name; in the case that the group has one job, it is
also the job name also the job name
size is the number of parallel jobs size is the number of parallel jobs
jobs: [{ name, needs}] jobs: [{ name, needs}]
job name is either the same as the group name or group x/y 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: Output is of the form:
{ nodes: [node], links: [link] } { nodes: [node], links: [link] }
...@@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash'; ...@@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash';
link: { source, target, value }, with source & target being node names link: { source, target, value }, with source & target being node names
and value being a constant 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 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. 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 CREATE LINKS
stages.groups.name -> target nodes.name -> target
stages.groups.needs.each -> source (source is the name of the group, not the parallel job) nodes.name.needs.each -> source (source is the name of the group, not the parallel job)
10 -> value (constant) 10 -> value (constant)
*/ */
export const createNodes = data => {
return data.flatMap(({ groups, name }) => {
return groups.map(group => {
return { ...group, category: name };
});
});
};
export const createNodeDict = nodes => { export const createNodeDict = nodes => {
return nodes.reduce((acc, node) => { return nodes.reduce((acc, node) => {
const newNode = { const newNode = {
...@@ -62,13 +51,6 @@ export const createNodeDict = nodes => { ...@@ -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) => { export const makeLinksFromNodes = (nodes, nodeDict) => {
const constantLinkValue = 10; // all links are the same weight const constantLinkValue = 10; // all links are the same weight
return nodes return nodes
...@@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) => ...@@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) =>
return !allAncestors.includes(source); return !allAncestors.includes(source);
}); });
export const parseData = data => { export const parseData = nodes => {
const { nodes, nodeDict } = createNodesStructure(data); const nodeDict = createNodeDict(nodes);
const allLinks = makeLinksFromNodes(nodes, nodeDict); const allLinks = makeLinksFromNodes(nodes, nodeDict);
const filteredLinks = filterByAncestors(allLinks, nodeDict); const filteredLinks = filterByAncestors(allLinks, nodeDict);
const links = uniqWith(filteredLinks, isEqual); const links = uniqWith(filteredLinks, isEqual);
......
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
}
}
}
}
}
}
}
}
}
}
}
...@@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate'; ...@@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue'; 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 GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator'; import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue'; import pipelineHeader from './components/header_component.vue';
...@@ -114,32 +114,6 @@ const createTestDetails = () => { ...@@ -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 () => { export default () => {
const { dataset } = document.querySelector('.js-pipeline-details-vue'); const { dataset } = document.querySelector('.js-pipeline-details-vue');
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
......
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;
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
- if dag_pipeline_tab_enabled - if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane #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-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
......
import { mount, shallowMount } from '@vue/test-utils'; 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 { GlAlert, GlEmptyState } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import Dag from '~/pipelines/components/dag/dag.vue'; import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue'; import DagAnnotations from '~/pipelines/components/dag/dag_annotations.vue';
...@@ -11,13 +8,11 @@ import { ...@@ -11,13 +8,11 @@ import {
ADD_NOTE, ADD_NOTE,
REMOVE_NOTE, REMOVE_NOTE,
REPLACE_NOTES, REPLACE_NOTES,
DEFAULT,
PARSE_FAILURE, PARSE_FAILURE,
LOAD_FAILURE,
UNSUPPORTED_DATA, UNSUPPORTED_DATA,
} from '~/pipelines/components/dag//constants'; } from '~/pipelines/components/dag//constants';
import { import {
mockBaseData, mockParsedGraphQLNodes,
tooSmallGraph, tooSmallGraph,
unparseableGraph, unparseableGraph,
graphWithoutDependencies, graphWithoutDependencies,
...@@ -27,7 +22,6 @@ import { ...@@ -27,7 +22,6 @@ import {
describe('Pipeline DAG graph wrapper', () => { describe('Pipeline DAG graph wrapper', () => {
let wrapper; let wrapper;
let mock;
const getAlert = () => wrapper.find(GlAlert); const getAlert = () => wrapper.find(GlAlert);
const getAllAlerts = () => wrapper.findAll(GlAlert); const getAllAlerts = () => wrapper.findAll(GlAlert);
const getGraph = () => wrapper.find(DagGraph); const getGraph = () => wrapper.find(DagGraph);
...@@ -35,67 +29,46 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -35,67 +29,46 @@ describe('Pipeline DAG graph wrapper', () => {
const getErrorText = type => wrapper.vm.$options.errorTexts[type]; const getErrorText = type => wrapper.vm.$options.errorTexts[type];
const getEmptyState = () => wrapper.find(GlEmptyState); const getEmptyState = () => wrapper.find(GlEmptyState);
const dataPath = '/root/test/pipelines/90/dag.json'; const createComponent = ({
graphData = mockParsedGraphQLNodes,
const createComponent = (propsData = {}, method = shallowMount) => { provideOverride = {},
method = shallowMount,
} = {}) => {
if (wrapper?.destroy) { if (wrapper?.destroy) {
wrapper.destroy(); wrapper.destroy();
} }
wrapper = method(Dag, { wrapper = method(Dag, {
propsData: { provide: {
pipelineProjectPath: 'root/abc-dag',
pipelineIid: '1',
emptySvgPath: '/my-svg', emptySvgPath: '/my-svg',
dagDocPath: '/my-doc', dagDocPath: '/my-doc',
...propsData, ...provideOverride,
}, },
data() { data() {
return { return {
graphData,
showFailureAlert: false, showFailureAlert: false,
}; };
}, },
}); });
}; };
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
mock.restore();
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
describe('when there is no dataUrl', () => { describe('when a query argument is undefined', () => {
beforeEach(() => { 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));
expect(getGraph().exists()).toBe(false);
}); });
it('does not render the empty state', () => { it('does not render the graph', async () => {
expect(getEmptyState().exists()).toBe(false);
});
});
describe('when there is a dataUrl', () => {
describe('but the data fetch 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); expect(getGraph().exists()).toBe(false);
}); });
...@@ -104,14 +77,12 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -104,14 +77,12 @@ describe('Pipeline DAG graph wrapper', () => {
}); });
}); });
describe('the data fetch succeeds but the parse fails', () => { describe('when all query variables are defined', () => {
describe('but the parse fails', () => {
beforeEach(async () => { beforeEach(async () => {
mock.onGet(dataPath).replyOnce(200, unparseableGraph); createComponent({
createComponent({ graphUrl: dataPath }); graphData: unparseableGraph,
});
await wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('shows the PARSE_FAILURE alert and not the graph', () => { it('shows the PARSE_FAILURE alert and not the graph', () => {
...@@ -125,14 +96,9 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -125,14 +96,9 @@ describe('Pipeline DAG graph wrapper', () => {
}); });
}); });
describe('and the data fetch and parse succeeds', () => { describe('parse succeeds', () => {
beforeEach(async () => { beforeEach(async () => {
mock.onGet(dataPath).replyOnce(200, mockBaseData); createComponent({ method: mount });
createComponent({ graphUrl: dataPath }, mount);
await wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('shows the graph', () => { it('shows the graph', () => {
...@@ -144,14 +110,11 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -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 () => { beforeEach(async () => {
mock.onGet(dataPath).replyOnce(200, tooSmallGraph); createComponent({
createComponent({ graphUrl: dataPath }); graphData: tooSmallGraph,
});
await wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('shows the UNSUPPORTED_DATA alert and not the graph', () => { it('shows the UNSUPPORTED_DATA alert and not the graph', () => {
...@@ -165,14 +128,12 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -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 () => { beforeEach(async () => {
mock.onGet(dataPath).replyOnce(200, graphWithoutDependencies); createComponent({
createComponent({ graphUrl: dataPath }, mount); method: mount,
graphData: graphWithoutDependencies,
await wrapper.vm.$nextTick(); });
return waitForPromises();
}); });
it('does not render an error alert or the graph', () => { it('does not render an error alert or the graph', () => {
...@@ -188,12 +149,7 @@ describe('Pipeline DAG graph wrapper', () => { ...@@ -188,12 +149,7 @@ describe('Pipeline DAG graph wrapper', () => {
describe('annotations', () => { describe('annotations', () => {
beforeEach(async () => { beforeEach(async () => {
mock.onGet(dataPath).replyOnce(200, mockBaseData); createComponent();
createComponent({ graphUrl: dataPath }, mount);
await wrapper.vm.$nextTick();
return waitForPromises();
}); });
it('toggles on link mouseover and mouseout', async () => { it('toggles on link mouseover and mouseout', async () => {
......
import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { parseData } from '~/pipelines/components/dag/parsing_utils'; import { parseData } from '~/pipelines/components/dag/parsing_utils';
import { mockBaseData } from './mock_data'; import { mockParsedGraphQLNodes } from './mock_data';
describe('DAG visualization drawing utilities', () => { describe('DAG visualization drawing utilities', () => {
const parsed = parseData(mockBaseData.stages); const parsed = parseData(mockParsedGraphQLNodes);
const layoutSettings = { const layoutSettings = {
width: 200, width: 200,
......
/* export const tooSmallGraph = [
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: [
{ {
category: 'test',
name: 'jest', name: 'jest',
size: 2, size: 2,
jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }],
}, },
{ {
category: 'test',
name: 'rspec', name: 'rspec',
size: 1, size: 1,
jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }],
}, },
],
},
{
name: 'fixtures',
groups: [
{ {
category: 'fixtures',
name: 'frontend fixtures', name: 'frontend fixtures',
size: 1, size: 1,
jobs: [{ name: 'frontend fixtures' }], jobs: [{ name: 'frontend fixtures' }],
}, },
],
},
{
name: 'un-needed',
groups: [
{ {
category: 'un-needed',
name: 'un-needed', name: 'un-needed',
size: 1, size: 1,
jobs: [{ name: 'un-needed' }], jobs: [{ name: 'un-needed' }],
}, },
], ];
},
],
};
export const graphWithoutDependencies = { export const graphWithoutDependencies = [
stages: [
{
name: 'test',
groups: [
{ {
category: 'test',
name: 'jest', name: 'jest',
size: 2, size: 2,
jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }],
}, },
{ {
category: 'test',
name: 'rspec', name: 'rspec',
size: 1, size: 1,
jobs: [{ name: 'rspec' }], jobs: [{ name: 'rspec' }],
}, },
],
},
{
name: 'fixtures',
groups: [
{ {
category: 'fixtures',
name: 'frontend fixtures', name: 'frontend fixtures',
size: 1, size: 1,
jobs: [{ name: 'frontend fixtures' }], jobs: [{ name: 'frontend fixtures' }],
}, },
],
},
{
name: 'un-needed',
groups: [
{ {
category: 'un-needed',
name: 'un-needed', name: 'un-needed',
size: 1, size: 1,
jobs: [{ name: 'un-needed' }], jobs: [{ name: 'un-needed' }],
}, },
], ];
},
],
};
export const unparseableGraph = [ export const unparseableGraph = [
{ {
...@@ -468,3 +397,264 @@ export const multiNote = { ...@@ -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',
},
];
import { import {
createNodesStructure, createNodeDict,
makeLinksFromNodes, makeLinksFromNodes,
filterByAncestors, filterByAncestors,
parseData, parseData,
...@@ -8,56 +8,17 @@ import { ...@@ -8,56 +8,17 @@ import {
} from '~/pipelines/components/dag/parsing_utils'; } from '~/pipelines/components/dag/parsing_utils';
import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { mockBaseData } from './mock_data'; import { mockParsedGraphQLNodes } from './mock_data';
describe('DAG visualization parsing utilities', () => { describe('DAG visualization parsing utilities', () => {
const { nodes, nodeDict } = createNodesStructure(mockBaseData.stages); const nodeDict = createNodeDict(mockParsedGraphQLNodes);
const unfilteredLinks = makeLinksFromNodes(nodes, nodeDict); const unfilteredLinks = makeLinksFromNodes(mockParsedGraphQLNodes, nodeDict);
const parsed = parseData(mockBaseData.stages); const parsed = parseData(mockParsedGraphQLNodes);
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', () => { describe('makeLinksFromNodes', () => {
it('returns the expected link structure', () => { it('returns the expected link structure', () => {
expect(unfilteredLinks[0]).toHaveProperty('source', 'frontend fixtures'); expect(unfilteredLinks[0]).toHaveProperty('source', 'build_a');
expect(unfilteredLinks[0]).toHaveProperty('target', 'jest'); expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a');
expect(unfilteredLinks[0]).toHaveProperty('value', 10); expect(unfilteredLinks[0]).toHaveProperty('value', 10);
}); });
}); });
...@@ -107,8 +68,22 @@ describe('DAG visualization parsing utilities', () => { ...@@ -107,8 +68,22 @@ describe('DAG visualization parsing utilities', () => {
describe('removeOrphanNodes', () => { describe('removeOrphanNodes', () => {
it('removes sankey nodes that have no needs and are not needed', () => { 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); 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);
}); });
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment