Commit 93739318 authored by Payton Burdette's avatar Payton Burdette Committed by Simon Knox

Build out lint results

Build the lint results
in Vue and setup apollo.
Add test coverage for components.
parent 47195fd4
<script>
import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui';
import CiLintResults from './ci_lint_results.vue';
import lintCIMutation from '../graphql/mutations/lint_ci.mutation.graphql';
export default {
components: {
GlButton,
GlFormCheckbox,
GlIcon,
GlLink,
GlAlert,
CiLintResults,
},
props: {
endpoint: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
},
data() {
return {
content: '',
valid: false,
errors: null,
warnings: null,
jobs: [],
dryRun: false,
showingResults: false,
apiError: null,
isErrorDismissed: false,
};
},
computed: {
shouldShowError() {
return this.apiError && !this.isErrorDismissed;
},
},
methods: {
async lint() {
try {
const {
data: {
lintCI: { valid, errors, warnings, jobs },
},
} = await this.$apollo.mutate({
mutation: lintCIMutation,
variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun },
});
this.showingResults = true;
this.valid = valid;
this.errors = errors;
this.warnings = warnings;
this.jobs = jobs;
} catch (error) {
this.apiError = error;
this.isErrorDismissed = false;
}
},
},
};
</script>
<template
><div></div
></template>
<template>
<div class="row">
<div class="col-sm-12">
<gl-alert
v-if="shouldShowError"
class="gl-mb-3"
variant="danger"
@dismiss="isErrorDismissed = true"
>{{ apiError }}</gl-alert
>
<textarea v-model="content" cols="175" rows="20"></textarea>
</div>
<div class="col-sm-12 gl-display-flex gl-justify-content-space-between">
<div class="gl-display-flex gl-align-items-center">
<gl-button class="gl-mr-4" category="primary" variant="success" @click="lint">{{
__('Validate')
}}</gl-button>
<gl-form-checkbox v-model="dryRun"
>{{ __('Simulate a pipeline created for the default branch') }}
<gl-link :href="helpPagePath" target="_blank"
><gl-icon class="gl-text-blue-600" name="question-o"/></gl-link
></gl-form-checkbox>
</div>
<gl-button>{{ __('Clear') }}</gl-button>
</div>
<ci-lint-results
v-if="showingResults"
:valid="valid"
:jobs="jobs"
:errors="errors"
:warnings="warnings"
:dry-run="dryRun"
/>
</div>
</template>
<script>
import { GlAlert, GlTable } from '@gitlab/ui';
import CiLintWarnings from './ci_lint_warnings.vue';
import CiLintResultsValue from './ci_lint_results_value.vue';
import CiLintResultsParam from './ci_lint_results_param.vue';
import { __ } from '~/locale';
const thBorderColor = 'gl-border-gray-100!';
export default {
props: {},
correct: { variant: 'success', text: __('syntax is correct') },
incorrect: { variant: 'danger', text: __('syntax is incorrect') },
warningTitle: __('The form contains the following warning:'),
fields: [
{
key: 'parameter',
label: __('Parameter'),
thClass: thBorderColor,
},
{
key: 'value',
label: __('Value'),
thClass: thBorderColor,
},
],
components: {
GlAlert,
GlTable,
CiLintWarnings,
CiLintResultsValue,
CiLintResultsParam,
},
props: {
valid: {
type: Boolean,
required: true,
},
jobs: {
type: Array,
required: true,
},
errors: {
type: Array,
required: true,
},
warnings: {
type: Array,
required: true,
},
dryRun: {
type: Boolean,
required: true,
},
},
data() {
return {
isWarningDismissed: false,
};
},
computed: {
status() {
return this.valid ? this.$options.correct : this.$options.incorrect;
},
shouldShowTable() {
return this.errors.length === 0;
},
shouldShowError() {
return this.errors.length > 0;
},
shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed;
},
},
};
</script>
<template
><div></div
></template>
<template>
<div class="col-sm-12 gl-mt-5">
<gl-alert
class="gl-mb-5"
:variant="status.variant"
:title="__('Status:')"
:dismissible="false"
data-testid="ci-lint-status"
>{{ status.text }}</gl-alert
>
<pre
v-if="shouldShowError"
class="gl-mb-5"
data-testid="ci-lint-errors"
><div v-for="error in errors" :key="error">{{ error }}</div></pre>
<ci-lint-warnings
v-if="shouldShowWarning"
:warnings="warnings"
data-testid="ci-lint-warnings"
@dismiss="isWarningDismissed = true"
/>
<gl-table
v-if="shouldShowTable"
:items="jobs"
:fields="$options.fields"
bordered
data-testid="ci-lint-table"
>
<template #cell(parameter)="{ item }">
<ci-lint-results-param :stage="item.stage" :job-name="item.name" />
</template>
<template #cell(value)="{ item }">
<ci-lint-results-value :item="item" :dry-run="dryRun" />
</template>
</gl-table>
</div>
</template>
<script>
import { __ } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export default {
props: {
stage: {
type: String,
required: true,
},
jobName: {
type: String,
required: true,
},
},
computed: {
formatParameter() {
return __(`${capitalizeFirstCharacter(this.stage)} Job - ${this.jobName}`);
},
},
};
</script>
<template>
<span data-testid="ci-lint-parameter">{{ formatParameter }}</span>
</template>
<script>
import { isEmpty } from 'lodash';
export default {
props: {
item: {
type: Object,
required: true,
},
dryRun: {
type: Boolean,
required: true,
},
},
computed: {
tagList() {
return this.item.tagList.join(', ');
},
onlyPolicy() {
return this.item.only ? this.item.only.refs.join(', ') : this.item.only;
},
exceptPolicy() {
return this.item.except ? this.item.except.refs.join(', ') : this.item.except;
},
scripts() {
return {
beforeScript: {
show: !isEmpty(this.item.beforeScript),
content: this.item.beforeScript.join('\n'),
},
script: {
show: !isEmpty(this.item.script),
content: this.item.script.join('\n'),
},
afterScript: {
show: !isEmpty(this.item.afterScript),
content: this.item.afterScript.join('\n'),
},
};
},
},
};
</script>
<template>
<div>
<pre v-if="scripts.beforeScript.show" data-testid="ci-lint-before-script">{{
scripts.beforeScript.content
}}</pre>
<pre v-if="scripts.script.show" data-testid="ci-lint-script">{{ scripts.script.content }}</pre>
<pre v-if="scripts.afterScript.show" data-testid="ci-lint-after-script">{{
scripts.afterScript.content
}}</pre>
<ul class="gl-list-style-none gl-pl-0 gl-mb-0">
<li>
<b>{{ __('Tag list:') }}</b>
{{ tagList }}
</li>
<div v-if="!dryRun" data-testid="ci-lint-only-except">
<li>
<b>{{ __('Only policy:') }}</b>
{{ onlyPolicy }}
</li>
<li>
<b>{{ __('Except policy:') }}</b>
{{ exceptPolicy }}
</li>
</div>
<li>
<b>{{ __('Environment:') }}</b>
{{ item.environment }}
</li>
<li>
<b>{{ __('When:') }}</b>
{{ item.when }}
<b v-if="item.allowFailure">{{ __('Allowed to fail') }}</b>
</li>
</ul>
</div>
</template>
<script>
import { GlAlert, GlSprintf } from '@gitlab/ui';
import { __, n__ } from '~/locale';
export default {
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
components: {
GlAlert,
GlSprintf,
},
props: {
warnings: {
type: Array,
required: true,
},
maxWarnings: {
type: Number,
required: false,
default: 25,
},
title: {
type: String,
required: false,
default: __('The form contains the following warning:'),
},
},
computed: {
totalWarnings() {
return this.warnings.length;
},
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
},
warningsSummary() {
return n__('%d warning found:', '%d warnings found:', this.totalWarnings);
},
summaryMessage() {
return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary;
},
limitWarnings() {
return this.warnings.slice(0, this.maxWarnings);
},
},
};
</script>
<template>
<gl-alert class="gl-mb-4" :title="title" variant="warning" @dismiss="$emit('dismiss')">
<details>
<summary>
<gl-sprintf :message="summaryMessage">
<template #total>
{{ totalWarnings }}
</template>
<template #warningsDisplayed>
{{ maxWarnings }}
</template>
</gl-sprintf>
</summary>
<p
v-for="(warning, index) in limitWarnings"
:key="`warning-${index}`"
data-testid="ci-lint-warning"
>
{{ warning }}
</p>
</details>
</gl-alert>
</template>
mutation lintCI($endpoint: String, $content: String, $dry: Boolean) {
lintCI(endpoint: $endpoint, content: $content, dry_run: $dry) @client {
valid
errors
warnings
jobs {
afterScript
allowFailure
beforeScript
environment
except
name
only {
refs
}
afterScript
stage
tagList
when
}
}
}
import Vue from 'vue';
import CILint from './components/ci_lint.vue';
import VueApollo from 'vue-apollo';
import axios from '~/lib/utils/axios_utils';
import createDefaultClient from '~/lib/graphql';
import CiLint from './components/ci_lint.vue';
Vue.use(VueApollo);
const resolvers = {
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
valid: data.valid,
errors: data.errors,
warnings: data.warnings,
jobs: data.jobs.map(job => ({
name: job.name,
stage: job.stage,
beforeScript: job.before_script,
script: job.script,
afterScript: job.after_script,
tagList: job.tag_list,
environment: job.environment,
when: job.when,
allowFailure: job.allow_failure,
only: {
refs: job.only.refs,
__typename: 'CiLintJobOnlyPolicy',
},
except: job.except,
__typename: 'CiLintJob',
})),
__typename: 'CiLintContent',
}));
},
},
};
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers),
});
export default (containerId = '#js-ci-lint') => {
const containerEl = document.querySelector(containerId);
const { endpoint } = containerEl.dataset;
const { endpoint, helpPagePath } = containerEl.dataset;
return new Vue({
el: containerEl,
apolloProvider,
render(createElement) {
return createElement(CILint, {
return createElement(CiLint, {
props: {
endpoint,
helpPagePath,
},
});
},
......
import CILintEditor from '../ci_lint_editor';
import initCILint from '~/ci_lint/index';
import createFlash from '~/flash';
import { __ } from '~/locale';
const ERROR = __('An error occurred while rendering the linter');
document.addEventListener('DOMContentLoaded', () => {
if (gon?.features?.ciLintVue) {
initCILint();
import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index')
.then(module => module.default())
.catch(() => createFlash(ERROR));
} else {
// eslint-disable-next-line no-new
new CILintEditor();
import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor')
// eslint-disable-next-line new-cap
.then(module => new module.default())
.catch(() => createFlash(ERROR));
}
});
import CILintEditor from '../ci_lint_editor';
import createFlash from '~/flash';
import { __ } from '~/locale';
document.addEventListener('DOMContentLoaded', () => new CILintEditor());
const ERROR = __('An error occurred while rendering the linter');
document.addEventListener('DOMContentLoaded', () => {
if (gon?.features?.ciLintVue) {
import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index')
.then(module => module.default())
.catch(() => createFlash(ERROR));
} else {
import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor')
// eslint-disable-next-line new-cap
.then(module => new module.default())
.catch(() => createFlash(ERROR));
}
});
......@@ -7,7 +7,7 @@
%h2.pt-3.pb-3= _("Validate your GitLab CI configuration")
- if Feature.enabled?(:ci_lint_vue, @project)
#js-ci-lint{ data: { endpoint: project_ci_lint_path(@project) } }
#js-ci-lint{ data: { endpoint: project_ci_lint_path(@project), help_page_path: help_page_path('ci/lint', anchor: 'pipeline-simulation') } }
- else
.project-ci-linter
......
......@@ -2938,6 +2938,9 @@ msgstr ""
msgid "An error occurred while rendering the editor"
msgstr ""
msgid "An error occurred while rendering the linter"
msgstr ""
msgid "An error occurred while reordering issues."
msgstr ""
......
import { shallowMount, mount } from '@vue/test-utils';
import { GlTable } from '@gitlab/ui';
import CiLintResults from '~/ci_lint/components/ci_lint_results.vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { mockJobs, mockErrors, mockWarnings } from '../mock_data';
describe('CI Lint Results', () => {
let wrapper;
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(CiLintResults, {
propsData: {
valid: true,
jobs: mockJobs,
errors: [],
warnings: [],
dryRun: false,
...props,
},
});
};
const findTable = () => wrapper.find(GlTable);
const findByTestId = selector => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`);
const findAllByTestId = selector => () => wrapper.findAll(`[data-testid="ci-lint-${selector}"]`);
const findErrors = findByTestId('errors');
const findWarnings = findByTestId('warnings');
const findStatus = findByTestId('status');
const findOnlyExcept = findByTestId('only-except');
const findLintParameters = findAllByTestId('parameter');
const findBeforeScripts = findAllByTestId('before-script');
const findScripts = findAllByTestId('script');
const findAfterScripts = findAllByTestId('after-script');
const filterEmptyScripts = property => mockJobs.filter(job => job[property].length !== 0);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('Invalid results', () => {
beforeEach(() => {
createComponent({ valid: false, errors: mockErrors, warnings: mockWarnings }, mount);
});
it('does not display the table', () => {
expect(findTable().exists()).toBe(false);
});
it('displays the invalid status', () => {
expect(findStatus().text()).toBe(`Status: ${wrapper.vm.$options.incorrect.text}`);
expect(findStatus().props('variant')).toBe(wrapper.vm.$options.incorrect.variant);
});
it('displays the error message', () => {
const [expectedError] = mockErrors;
expect(findErrors().text()).toBe(expectedError);
});
it('displays the warning message', () => {
const [expectedWarning] = mockWarnings;
expect(findWarnings().exists()).toBe(true);
expect(findWarnings().text()).toContain(expectedWarning);
});
});
describe('Valid results', () => {
beforeEach(() => {
createComponent();
});
it('displays table', () => {
expect(findTable().exists()).toBe(true);
});
it('displays the valid status', () => {
expect(findStatus().text()).toBe(wrapper.vm.$options.correct.text);
expect(findStatus().props('variant')).toBe(wrapper.vm.$options.correct.variant);
});
it('does not display only/expect values with dry run', () => {
expect(findOnlyExcept().exists()).toBe(false);
});
});
describe('Lint results', () => {
beforeEach(() => {
createComponent({}, mount);
});
it('formats parameter value', () => {
findLintParameters().wrappers.forEach((job, index) => {
const { stage } = mockJobs[index];
const { name } = mockJobs[index];
expect(job.text()).toBe(`${capitalizeFirstCharacter(stage)} Job - ${name}`);
});
});
it('only shows before scripts when data is present', () => {
expect(findBeforeScripts()).toHaveLength(filterEmptyScripts('beforeScript').length);
});
it('only shows script when data is present', () => {
expect(findScripts()).toHaveLength(filterEmptyScripts('script').length);
});
it('only shows after script when data is present', () => {
expect(findAfterScripts()).toHaveLength(filterEmptyScripts('afterScript').length);
});
});
});
import { mount } from '@vue/test-utils';
import { GlAlert, GlSprintf } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import CiLintWarnings from '~/ci_lint/components/ci_lint_warnings.vue';
const warnings = ['warning 1', 'warning 2', 'warning 3'];
describe('CI lint warnings', () => {
let wrapper;
const createComponent = (limit = 25) => {
wrapper = mount(CiLintWarnings, {
propsData: {
warnings,
maxWarnings: limit,
},
});
};
const findWarningAlert = () => wrapper.find(GlAlert);
const findWarnings = () => wrapper.findAll('[data-testid="ci-lint-warning"]');
const findWarningMessage = () => trimText(wrapper.find(GlSprintf).text());
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays the warning alert', () => {
createComponent();
expect(findWarningAlert().exists()).toBe(true);
});
it('displays all the warnings', () => {
createComponent();
expect(findWarnings()).toHaveLength(warnings.length);
});
it('shows the correct message when the limit is not passed', () => {
createComponent();
expect(findWarningMessage()).toBe(`${warnings.length} warnings found:`);
});
it('shows the correct message when the limit is passed', () => {
const limit = 2;
createComponent(limit);
expect(findWarningMessage()).toBe(`${warnings.length} warnings found: showing first ${limit}`);
});
});
export const mockJobs = [
{
name: 'job_1',
stage: 'build',
beforeScript: [],
script: ["echo 'Building'"],
afterScript: [],
tagList: [],
environment: null,
when: 'on_success',
allowFailure: true,
only: { refs: ['web', 'chat', 'pushes'] },
except: null,
},
{
name: 'multi_project_job',
stage: 'test',
beforeScript: [],
script: [],
afterScript: [],
tagList: [],
environment: null,
when: 'on_success',
allowFailure: false,
only: { refs: ['branches', 'tags'] },
except: null,
},
{
name: 'job_2',
stage: 'test',
beforeScript: ["echo 'before script'"],
script: ["echo 'script'"],
afterScript: ["echo 'after script"],
tagList: [],
environment: null,
when: 'on_success',
allowFailure: false,
only: { refs: ['branches@gitlab-org/gitlab'] },
except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] },
},
];
export const mockErrors = [
'"job_1 job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post"',
];
export const mockWarnings = [
'"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"',
];
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