Commit fa9578bb authored by Sarah Groff Hennigh-Palermo's avatar Sarah Groff Hennigh-Palermo

Merge branch 'fc-ci-viz-tab' into 'master'

Add visualization functionality to Authoring home

See merge request gitlab-org/gitlab!48793
parents d4f8d92b 66dbb733
export const CI_CONFIG_STATUS_VALID = 'VALID';
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
......@@ -2,6 +2,7 @@
import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import CommitForm from './components/commit/commit_form.vue';
......@@ -31,6 +32,7 @@ export default {
PipelineGraph,
TextEditor,
},
mixins: [glFeatureFlagsMixin()],
props: {
projectPath: {
type: String,
......@@ -115,6 +117,9 @@ export default {
isBlobContentLoading() {
return this.$apollo.queries.content.loading;
},
isVisualizationTabLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
isVisualizeTabActive() {
return this.currentTabIndex === 1;
},
......@@ -266,8 +271,14 @@ export default {
<text-editor v-model="contentModel" @editor-ready="editorIsReady = true" />
</gl-tab>
<gl-tab :title="$options.i18n.tabGraph" :lazy="!isVisualizeTabActive">
<pipeline-graph :pipeline-data="ciConfigData" />
<gl-tab
v-if="glFeatures.ciConfigVisualizationTab"
:title="$options.i18n.tabGraph"
:lazy="!isVisualizeTabActive"
data-testid="visualization-tab"
>
<gl-loading-icon v-if="isVisualizationTabLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</gl-tab>
</gl-tabs>
</div>
......
import * as d3 from 'd3';
import { createUniqueJobId } from '../../utils';
import { createUniqueLinkId } from '../../utils';
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
......@@ -12,13 +12,13 @@ import { createUniqueJobId } from '../../utils';
* @returns {Array} Links that contain all the information about them
*/
export const generateLinksData = ({ links }, jobs, containerID) => {
export const generateLinksData = ({ links }, containerID) => {
const containerEl = document.getElementById(containerID);
return links.map(link => {
const path = d3.path();
const sourceId = jobs[link.source].id;
const targetId = jobs[link.target].id;
const sourceId = link.source;
const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
......@@ -89,7 +89,7 @@ export const generateLinksData = ({ links }, jobs, containerID) => {
...link,
source: sourceId,
target: targetId,
ref: createUniqueJobId(sourceId, targetId),
ref: createUniqueLinkId(sourceId, targetId),
path: path.toString(),
};
});
......
......@@ -10,10 +10,6 @@ export default {
type: String,
required: true,
},
jobId: {
type: String,
required: true,
},
isHighlighted: {
type: Boolean,
required: false,
......@@ -45,7 +41,7 @@ export default {
},
methods: {
onMouseEnter() {
this.$emit('on-mouse-enter', this.jobId);
this.$emit('on-mouse-enter', this.jobName);
},
onMouseLeave() {
this.$emit('on-mouse-leave');
......@@ -56,7 +52,7 @@ export default {
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
:id="jobId"
:id="jobName"
class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
:class="jobPillClasses"
@mouseover="onMouseEnter"
......
......@@ -6,8 +6,10 @@ import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT } from '../../constants';
import { generateJobNeedsDict } from '../../utils';
import { unwrapArrayOfJobs } from '../unwrapping_utils';
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
export default {
components: {
......@@ -22,6 +24,12 @@ export default {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
},
warningTexts: {
[EMPTY_PIPELINE_DATA]: __(
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
),
[INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
},
props: {
pipelineData: {
required: true,
......@@ -40,18 +48,51 @@ export default {
},
computed: {
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
return !this.isInvalidCiConfig && isEmpty(this.pipelineData?.stages);
},
isInvalidCiConfig() {
return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
},
showAlert() {
return this.hasError || this.hasWarning;
},
hasError() {
return this.failureType;
},
hasWarning() {
return this.warning;
},
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
alert() {
if (this.hasError) {
return this.failure;
}
return this.warning;
},
failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
return { text, variant: 'danger' };
return { text, variant: 'danger', dismissible: true };
},
warning() {
if (this.isPipelineDataEmpty) {
return {
text: this.$options.warningTexts[EMPTY_PIPELINE_DATA],
variant: 'tip',
dismissible: false,
};
} else if (this.isInvalidCiConfig) {
return {
text: this.$options.warningTexts[INVALID_CI_CONFIG],
variant: 'danger',
dismissible: false,
};
}
return null;
},
viewBox() {
return [0, 0, this.width, this.height];
......@@ -80,19 +121,21 @@ export default {
},
},
mounted() {
if (!this.isPipelineDataEmpty) {
if (!this.isPipelineDataEmpty && !this.isInvalidCiConfig) {
// This guarantee that all sub-elements are rendered
// https://v3.vuejs.org/api/options-lifecycle-hooks.html#mounted
this.$nextTick(() => {
this.getGraphDimensions();
this.drawJobLinks();
this.prepareLinkData();
});
}
},
methods: {
drawJobLinks() {
const { stages, jobs } = this.pipelineData;
const unwrappedGroups = this.unwrapPipelineData(stages);
prepareLinkData() {
try {
const parsedData = parseData(unwrappedGroups);
this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
const arrayOfJobs = unwrapArrayOfJobs(this.pipelineData);
const parsedData = parseData(arrayOfJobs);
this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
} catch {
this.reportFailure(DRAW_FAILURE);
}
......@@ -119,7 +162,8 @@ export default {
// The first time we hover, we create the object where
// we store all the data to properly highlight the needs.
if (!this.needsObject) {
this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {};
const jobs = createJobsHash(this.pipelineData);
this.needsObject = generateJobNeedsDict(jobs) ?? {};
}
this.highlightedJob = uniqueJobId;
......@@ -127,18 +171,9 @@ export default {
removeHighlightNeeds() {
this.highlightedJob = null;
},
unwrapPipelineData(stages) {
return stages
.map(({ name, groups }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
},
getGraphDimensions() {
this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`;
this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`;
this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
},
reportFailure(errorType) {
this.failureType = errorType;
......@@ -163,21 +198,20 @@ export default {
</script>
<template>
<div>
<gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
{{ failure.text }}
</gl-alert>
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
{{
__(
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
)
}}
<gl-alert
v-if="showAlert"
:variant="alert.variant"
:dismissible="alert.dismissible"
@dismiss="alert.dismissible ? resetFailure : null"
>
{{ alert.text }}
</gl-alert>
<div
v-else
v-if="!hasWarning"
:id="$options.CONTAINER_ID"
:ref="$options.CONTAINER_REF"
class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
data-testid="graph-container"
>
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
<template>
......@@ -210,10 +244,9 @@ export default {
<job-pill
v-for="group in stage.groups"
:key="group.name"
:job-id="group.id"
:job-name="group.name"
:is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)"
:is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)"
:is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
:is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
@on-mouse-enter="highlightNeeds"
@on-mouse-leave="removeHighlightNeeds"
/>
......
/**
* This function takes the stages and add the stage name
* at the group level as `category` to have an easier
* implementation while constructions nodes with D3
* @param {Array} stages
* @returns {Array} - Array of stages with stage name at the group level as `category`
*/
export const unwrapArrayOfJobs = (stages = []) => {
return stages
.map(({ name, groups }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
};
const unwrapGroups = stages => {
return stages.map(stage => {
const {
......
......@@ -28,6 +28,8 @@ export const RAW_TEXT_WARNING = s__(
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure';
export const EMPTY_PIPELINE_DATA = 'empty_data';
export const INVALID_CI_CONFIG = 'invalid_ci_config';
export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
......
......@@ -5,9 +5,42 @@ export const validateParams = params => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
export const generateJobNeedsDict = ({ jobs }) => {
/**
* This function takes the stages array and transform it
* into a hash where each key is a job name and the job data
* is associated to that key.
* @param {Array} stages
* @returns {Object} - Hash of jobs
*/
export const createJobsHash = (stages = []) => {
const jobsHash = {};
stages.forEach(stage => {
if (stage.groups.length > 0) {
stage.groups.forEach(group => {
group.jobs.forEach(job => {
jobsHash[job.name] = job;
});
});
}
});
return jobsHash;
};
/**
* This function takes the jobs hash generated by
* `createJobsHash` function and returns an easier
* structure to work with for needs relationship
* where the key is the job name and the value is an
* array of all the needs this job has recursively
* (includes the needs of the needs)
* @param {Object} jobs
* @returns {Object} - Hash of jobs and array of needs
*/
export const generateJobNeedsDict = (jobs = {}) => {
const arrOfJobNames = Object.keys(jobs);
return arrOfJobNames.reduce((acc, value) => {
......@@ -18,13 +51,12 @@ export const generateJobNeedsDict = ({ jobs }) => {
return jobs[jobName].needs
.map(job => {
const { id } = jobs[job];
// If we already have the needs of a job in the accumulator,
// then we use the memoized data instead of the recursive call
// to save some performance.
const newNeeds = acc[id] ?? recursiveNeeds(job);
const newNeeds = acc[job] ?? recursiveNeeds(job);
return [id, ...newNeeds];
return [job, ...newNeeds];
})
.flat(Infinity);
};
......@@ -34,6 +66,6 @@ export const generateJobNeedsDict = ({ jobs }) => {
// duplicates from the array.
const uniqueValues = Array.from(new Set(recursiveNeeds(value)));
return { ...acc, [jobs[value].id]: uniqueValues };
return { ...acc, [value]: uniqueValues };
}, {});
};
......@@ -2,6 +2,9 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: false)
end
feature_category :pipeline_authoring
......
---
name: ci_config_visualization_tab
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48793
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/290117
milestone: '13.7'
type: development
group: group::pipeline authoring
default_enabled: false
......@@ -7,6 +7,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Visualize your CI/CD configuration
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241722) in GitLab 13.5.
> - [Moved to **CI/CD > Editor**](https://gitlab.com/gitlab-org/gitlab/-/issues/263141) in GitLab 13.7.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
......@@ -15,16 +16,15 @@ info: To determine the technical writer assigned to the Stage/Group associated w
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
To see a visualization of your `gitlab-ci.yml` configuration, navigate to any CI/CD
configuration file and click on the `Visualization` tab. The visualization shows
all stages and jobs. [`needs`](README.md#needs) relationships are displayed as lines
connecting jobs together, showing the hierarchy of execution:
To see a visualization of your `gitlab-ci.yml` configuration, navigate to **CI/CD > Editor**
and select the `Visualization` tab. The visualization shows all stages and jobs.
[`needs`](README.md#needs) relationships are displayed as lines connecting jobs together, showing the hierarchy of execution:
![CI Configuration Visualization](img/ci_config_visualization_v13_5.png)
![CI Config Visualization](img/ci_config_visualization_v13_7.png)
Hovering on a job highlights its `needs` relationships:
![CI Configuration Visualization on hover](img/ci_config_visualization_hover_v13_5.png)
![CI Config Visualization on hover](img/ci_config_visualization_hover_v13_7.png)
If the configuration does not have any `needs` relationships, then no lines are drawn because
each job depends only on the previous stage being completed successfully.
......@@ -42,11 +42,11 @@ can enable it.
To enable it:
```ruby
Feature.enable(:gitlab_ci_yml_preview)
Feature.enable(:ci_config_visualization_tab)
```
To disable it:
```ruby
Feature.disable(:gitlab_ci_yml_preview)
Feature.disable(:ci_config_visualization_tab)
```
......@@ -32062,6 +32062,9 @@ msgstr ""
msgid "Your %{strong}%{plan_name}%{strong_close} subscription will expire on %{strong}%{expires_on}%{strong_close}. After that, you will not be able to create issues or merge requests as well as many other features."
msgstr ""
msgid "Your CI configuration file is invalid."
msgstr ""
msgid "Your CSV export has started. It will be emailed to %{email} when complete."
msgstr ""
......
......@@ -51,9 +51,15 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const createComponent = ({
props = {},
loading = false,
blobLoading = false,
lintLoading = false,
options = {},
mountFn = shallowMount,
provide = {
glFeatures: {
ciConfigVisualizationTab: true,
},
},
} = {}) => {
mockMutate = jest.fn().mockResolvedValue({
data: {
......@@ -73,6 +79,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
newMergeRequestPath: mockNewMergeRequestPath,
...props,
},
provide,
stubs: {
GlTabs,
GlButton,
......@@ -86,7 +93,10 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
$apollo: {
queries: {
content: {
loading,
loading: blobLoading,
},
ciConfigData: {
loading: lintLoading,
},
},
mutate: mockMutate,
......@@ -124,9 +134,12 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findAlert = () => wrapper.find(GlAlert);
const findBlobFailureAlert = () => wrapper.find(GlAlert);
const findTabAt = i => wrapper.findAll(GlTab).at(i);
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findTextEditor = () => wrapper.find(TextEditor);
const findCommitForm = () => wrapper.find(CommitForm);
const findPipelineGraph = () => wrapper.find(PipelineGraph);
const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon);
beforeEach(() => {
......@@ -145,32 +158,28 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
wrapper = null;
});
it('displays a loading icon if the query is loading', () => {
createComponent({ loading: true });
it('displays a loading icon if the blob query is loading', () => {
createComponent({ blobLoading: true });
expect(findLoadingIcon().exists()).toBe(true);
expect(findTextEditor().exists()).toBe(false);
});
describe('tabs', () => {
describe('editor tab', () => {
beforeEach(() => {
createComponent();
});
it('displays tabs and their content', async () => {
it('displays the tab and its content', async () => {
expect(
findTabAt(0)
.find(TextEditor)
.exists(),
).toBe(true);
expect(
findTabAt(1)
.find(PipelineGraph)
.exists(),
).toBe(true);
});
it('displays editor tab lazily, until editor is ready', async () => {
it('displays tab lazily, until editor is ready', async () => {
expect(findTabAt(0).attributes('lazy')).toBe('true');
findTextEditor().vm.$emit('editor-ready');
......@@ -181,6 +190,36 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
});
});
describe('visualization tab', () => {
describe('with feature flag on', () => {
beforeEach(() => {
createComponent();
});
it('display the tab', () => {
expect(findVisualizationTab().exists()).toBe(true);
});
it('displays a loading icon if the lint query is loading', () => {
createComponent({ lintLoading: true });
expect(findLoadingIcon().exists()).toBe(true);
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('with feature flag off', () => {
beforeEach(() => {
createComponent({ provide: { glFeatures: { ciConfigVisualizationTab: false } } });
});
it('does not display the tab', () => {
expect(findVisualizationTab().exists()).toBe(false);
});
});
});
});
describe('when data is set', () => {
beforeEach(async () => {
createComponent({ mountFn: mount });
......@@ -359,7 +398,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises();
expect(findAlert().exists()).toBe(false);
expect(findBlobFailureAlert().exists()).toBe(false);
expect(findTextEditor().attributes('value')).toBe(mockCiYml);
});
......@@ -373,7 +412,9 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises();
expect(findAlert().text()).toMatch('No CI file found in this repository, please add one.');
expect(findBlobFailureAlert().text()).toBe(
'No CI file found in this repository, please add one.',
);
});
it('shows a 400 error message', async () => {
......@@ -386,7 +427,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises();
expect(findAlert().text()).toMatch(
expect(findBlobFailureAlert().text()).toBe(
'Repository does not have a default branch, please set one.',
);
});
......@@ -396,7 +437,9 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
createComponentWithApollo();
await waitForPromises();
expect(findAlert().text()).toMatch('The CI configuration was not loaded, please try again.');
expect(findBlobFailureAlert().text()).toBe(
'The CI configuration was not loaded, please try again.',
);
});
});
});
import { createUniqueJobId } from '~/pipelines/utils';
import { createUniqueLinkId } from '~/pipelines/utils';
export const yamlString = `stages:
- empty
......@@ -41,10 +41,10 @@ deploy_a:
script: echo hello
`;
const jobId1 = createUniqueJobId('build', 'build_1');
const jobId2 = createUniqueJobId('test', 'test_1');
const jobId3 = createUniqueJobId('test', 'test_2');
const jobId4 = createUniqueJobId('deploy', 'deploy_1');
const jobId1 = createUniqueLinkId('build', 'build_1');
const jobId2 = createUniqueLinkId('test', 'test_1');
const jobId3 = createUniqueLinkId('test', 'test_2');
const jobId4 = createUniqueLinkId('deploy', 'deploy_1');
export const pipelineData = {
stages: [
......
import { shallowMount } from '@vue/test-utils';
import { GlAlert } from '@gitlab/ui';
import { pipelineData, singleStageData } from './mock_data';
import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
import { DRAW_FAILURE, EMPTY_PIPELINE_DATA, INVALID_CI_CONFIG } from '~/pipelines/constants';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
......@@ -8,15 +11,16 @@ describe('pipeline graph component', () => {
const defaultProps = { pipelineData };
let wrapper;
const createComponent = props => {
const createComponent = (props = defaultProps) => {
return shallowMount(PipelineGraph, {
propsData: {
...defaultProps,
...props,
},
});
};
const findPipelineGraph = () => wrapper.find('[data-testid="graph-container"]');
const findAlert = () => wrapper.find(GlAlert);
const findAllStagePills = () => wrapper.findAll(StagePill);
const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]');
const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index);
......@@ -33,54 +37,92 @@ describe('pipeline graph component', () => {
});
it('renders an empty section', () => {
expect(wrapper.text()).toContain(
'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
);
expect(wrapper.text()).toBe(wrapper.vm.$options.warningTexts[EMPTY_PIPELINE_DATA]);
expect(findPipelineGraph().exists()).toBe(false);
expect(findAllStagePills()).toHaveLength(0);
expect(findAllJobPills()).toHaveLength(0);
});
});
describe('with data', () => {
describe('with `INVALID` status', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: { status: CI_CONFIG_STATUS_INVALID } });
});
it('renders an error message and does not render the graph', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]);
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('without `INVALID` status', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the graph with no status error', () => {
expect(findAlert().text()).not.toBe(wrapper.vm.$options.warningTexts[INVALID_CI_CONFIG]);
expect(findPipelineGraph().exists()).toBe(true);
});
});
describe('with error while rendering the links', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('renders the error that link could not be drawn', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(wrapper.vm.$options.errorTexts[DRAW_FAILURE]);
});
});
describe('with only one stage', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: singleStageData });
});
it('renders the right number of stage pills', () => {
const expectedStagesLength = pipelineData.stages.length;
const expectedStagesLength = singleStageData.stages.length;
expect(findAllStagePills()).toHaveLength(expectedStagesLength);
});
it('renders the right number of job pills', () => {
// We count the number of jobs in the mock data
const expectedJobsLength = singleStageData.stages.reduce((acc, val) => {
return acc + val.groups.length;
}, 0);
expect(findAllJobPills()).toHaveLength(expectedJobsLength);
});
describe('rounds corner', () => {
it.each`
cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${true}
${'gl-rounded-top-left-6'} | ${true}
${'gl-rounded-top-right-6'} | ${false}
${'gl-rounded-bottom-right-6'} | ${false}
`(
'rounds corner: $class should be $expectedState on the first element',
({ cssClass, expectedState }) => {
${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${true}
`('$cssClass should be $expectedState on the only element', ({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(0).classes();
expect(classes.includes(cssClass)).toBe(expectedState);
},
);
});
});
});
it.each`
cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${false}
${'gl-rounded-top-left-6'} | ${false}
${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${true}
`(
'rounds corner: $class should be $expectedState on the last element',
({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes();
describe('with multiple stages and jobs', () => {
beforeEach(() => {
wrapper = createComponent();
});
expect(classes.includes(cssClass)).toBe(expectedState);
},
);
it('renders the right number of stage pills', () => {
const expectedStagesLength = pipelineData.stages.length;
expect(findAllStagePills()).toHaveLength(expectedStagesLength);
});
it('renders the right number of job pills', () => {
// We count the number of jobs in the mock data
......@@ -90,26 +132,34 @@ describe('pipeline graph component', () => {
expect(findAllJobPills()).toHaveLength(expectedJobsLength);
});
});
describe('with only one stage', () => {
beforeEach(() => {
wrapper = createComponent({ pipelineData: singleStageData });
});
describe('rounds corner', () => {
it.each`
cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${true}
${'gl-rounded-top-left-6'} | ${true}
${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${true}
${'gl-rounded-top-right-6'} | ${false}
${'gl-rounded-bottom-right-6'} | ${false}
`(
'rounds corner: $class should be $expectedState on the only element',
'$cssClass should be $expectedState on the first element',
({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(0).classes();
expect(classes.includes(cssClass)).toBe(expectedState);
},
);
it.each`
cssClass | expectedState
${'gl-rounded-bottom-left-6'} | ${false}
${'gl-rounded-top-left-6'} | ${false}
${'gl-rounded-top-right-6'} | ${true}
${'gl-rounded-bottom-right-6'} | ${true}
`('$cssClass should be $expectedState on the last element', ({ cssClass, expectedState }) => {
const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes();
expect(classes.includes(cssClass)).toBe(expectedState);
});
});
});
});
import { createUniqueJobId, generateJobNeedsDict } from '~/pipelines/utils';
import { createJobsHash, generateJobNeedsDict } from '~/pipelines/utils';
describe('utils functions', () => {
const jobName1 = 'build_1';
const jobName2 = 'build_2';
const jobName3 = 'test_1';
const jobName4 = 'deploy_1';
const job1 = { script: 'echo hello', stage: 'build' };
const job2 = { script: 'echo build', stage: 'build' };
const job3 = { script: 'echo test', stage: 'test', needs: [jobName1, jobName2] };
const job4 = { script: 'echo deploy', stage: 'deploy', needs: [jobName3] };
const job1 = { name: jobName1, script: 'echo hello', stage: 'build' };
const job2 = { name: jobName2, script: 'echo build', stage: 'build' };
const job3 = {
name: jobName3,
script: 'echo test',
stage: 'test',
needs: [jobName1, jobName2],
};
const job4 = {
name: jobName4,
script: 'echo deploy',
stage: 'deploy',
needs: [jobName3],
};
const userDefinedStage = 'myStage';
const pipelineGraphData = {
......@@ -23,7 +33,6 @@ describe('utils functions', () => {
{
name: jobName4,
jobs: [{ ...job4 }],
id: createUniqueJobId(job4.stage, jobName4),
},
],
},
......@@ -33,12 +42,10 @@ describe('utils functions', () => {
{
name: jobName1,
jobs: [{ ...job1 }],
id: createUniqueJobId(job1.stage, jobName1),
},
{
name: jobName2,
jobs: [{ ...job2 }],
id: createUniqueJobId(job2.stage, jobName2),
},
],
},
......@@ -48,49 +55,59 @@ describe('utils functions', () => {
{
name: jobName3,
jobs: [{ ...job3 }],
id: createUniqueJobId(job3.stage, jobName3),
},
],
},
],
jobs: {
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
[jobName3]: { ...job3, id: createUniqueJobId(job3.stage, jobName3) },
[jobName4]: { ...job4, id: createUniqueJobId(job4.stage, jobName4) },
},
};
describe('createJobsHash', () => {
it('returns an empty object if there are no jobs received as argument', () => {
expect(createJobsHash([])).toEqual({});
});
it('returns a hash with the jobname as key and all its data as value', () => {
const jobs = {
[jobName1]: job1,
[jobName2]: job2,
[jobName3]: job3,
[jobName4]: job4,
};
expect(createJobsHash(pipelineGraphData.stages)).toEqual(jobs);
});
});
describe('generateJobNeedsDict', () => {
it('generates an empty object if it receives no jobs', () => {
expect(generateJobNeedsDict({ jobs: {} })).toEqual({});
expect(generateJobNeedsDict({})).toEqual({});
});
it('generates a dict with empty needs if there are no dependencies', () => {
const smallGraph = {
jobs: {
[jobName1]: { ...job1, id: createUniqueJobId(job1.stage, jobName1) },
[jobName2]: { ...job2, id: createUniqueJobId(job2.stage, jobName2) },
},
[jobName1]: job1,
[jobName2]: job2,
};
expect(generateJobNeedsDict(smallGraph)).toEqual({
[pipelineGraphData.jobs[jobName1].id]: [],
[pipelineGraphData.jobs[jobName2].id]: [],
[jobName1]: [],
[jobName2]: [],
});
});
it('generates a dict where key is the a job and its value is an array of all its needs', () => {
const uniqueJobName1 = pipelineGraphData.jobs[jobName1].id;
const uniqueJobName2 = pipelineGraphData.jobs[jobName2].id;
const uniqueJobName3 = pipelineGraphData.jobs[jobName3].id;
const uniqueJobName4 = pipelineGraphData.jobs[jobName4].id;
const jobsWithNeeds = {
[jobName1]: job1,
[jobName2]: job2,
[jobName3]: job3,
[jobName4]: job4,
};
expect(generateJobNeedsDict(pipelineGraphData)).toEqual({
[uniqueJobName1]: [],
[uniqueJobName2]: [],
[uniqueJobName3]: [uniqueJobName1, uniqueJobName2],
[uniqueJobName4]: [uniqueJobName3, uniqueJobName1, uniqueJobName2],
expect(generateJobNeedsDict(jobsWithNeeds)).toEqual({
[jobName1]: [],
[jobName2]: [],
[jobName3]: [jobName1, jobName2],
[jobName4]: [jobName3, jobName1, jobName2],
});
});
});
......
import {
unwrapArrayOfJobs,
unwrapGroups,
unwrapNodesWithName,
unwrapStagesWithNeeds,
......@@ -94,6 +95,29 @@ const completeMock = [
];
describe('Shared pipeline unwrapping utils', () => {
describe('unwrapArrayOfJobs', () => {
it('returns an empty array if the input is an empty undefined', () => {
expect(unwrapArrayOfJobs(undefined)).toEqual([]);
});
it('returns an empty array if the input is an empty array', () => {
expect(unwrapArrayOfJobs([])).toEqual([]);
});
it('returns a flatten array of each job with their data and stage name', () => {
expect(
unwrapArrayOfJobs([
{ name: 'build', groups: [{ name: 'job_a_1' }, { name: 'job_a_2' }] },
{ name: 'test', groups: [{ name: 'job_b' }] },
]),
).toMatchObject([
{ category: 'build', name: 'job_a_1' },
{ category: 'build', name: 'job_a_2' },
{ category: 'test', name: 'job_b' },
]);
});
});
describe('unwrapGroups', () => {
it('takes stages without nodes and returns the unwrapped groups', () => {
expect(unwrapGroups(stagesAndGroups)[0].groups).toEqual(groupsArray);
......
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