Commit 8f07b5a4 authored by Miguel Rincon's avatar Miguel Rincon

Add static validation to gitlab CI yaml

This change fetches the external YML schema for the gitlab-ci file
and adds it to the Pipeline/CI Editor.

Adds option `inheritAttrs: false` to prevent the
contents of the file from being added to the text editor <div>
container.
parent aaec0061
<script> <script>
import EditorLite from '~/vue_shared/components/editor_lite.vue'; import EditorLite from '~/vue_shared/components/editor_lite.vue';
import { CiSchemaExtension } from '~/editor/editor_ci_schema_ext';
export default { export default {
components: { components: {
EditorLite, EditorLite,
}, },
inheritAttrs: false,
props: {
ciConfigPath: {
type: String,
required: true,
},
commitSha: {
type: String,
required: false,
default: null,
},
projectPath: {
type: String,
required: true,
},
},
methods: {
onEditorReady() {
const editorInstance = this.$refs.editor.getEditor();
const [projectNamespace, projectPath] = this.projectPath.split('/');
editorInstance.use(new CiSchemaExtension());
editorInstance.registerCiSchema({
projectPath,
projectNamespace,
ref: this.commitSha,
});
},
},
}; };
</script> </script>
<template> <template>
<div class="gl-border-solid gl-border-gray-100 gl-border-1"> <div class="gl-border-solid gl-border-gray-100 gl-border-1">
<editor-lite file-name="*.yml" v-bind="$attrs" v-on="$listeners" /> <editor-lite
ref="editor"
:file-name="ciConfigPath"
v-bind="$attrs"
@editor-ready="onEditorReady"
v-on="$listeners"
/>
</div> </div>
</template> </template>
...@@ -277,7 +277,13 @@ export default { ...@@ -277,7 +277,13 @@ export default {
<!-- editor should be mounted when its tab is visible, so the container has a size --> <!-- editor should be mounted when its tab is visible, so the container has a size -->
<gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady"> <gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
<!-- editor should be mounted only once, when the tab is displayed --> <!-- editor should be mounted only once, when the tab is displayed -->
<text-editor v-model="contentModel" @editor-ready="editorIsReady = true" /> <text-editor
v-model="contentModel"
:ci-config-path="ciConfigPath"
:commit-sha="lastCommitSha"
:project-path="projectPath"
@editor-ready="editorIsReady = true"
/>
</gl-tab> </gl-tab>
<gl-tab <gl-tab
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import EditorLite from '~/vue_shared/components/editor_lite.vue'; import {
import { mockCiYml } from '../mock_data'; mockCiConfigPath,
mockCiYml,
mockCommitSha,
mockProjectPath,
mockNamespace,
mockProjectName,
} from '../mock_data';
import TextEditor from '~/pipeline_editor/components/text_editor.vue'; import TextEditor from '~/pipeline_editor/components/text_editor.vue';
describe('~/pipeline_editor/components/text_editor.vue', () => { describe('~/pipeline_editor/components/text_editor.vue', () => {
let wrapper; let wrapper;
const editorReadyListener = jest.fn();
const createComponent = (attrs = {}, mountFn = shallowMount) => { let editorReadyListener;
let mockUse;
let mockRegisterCiSchema;
const MockEditorLite = {
template: '<div/>',
props: ['value', 'fileName'],
mounted() {
this.$emit('editor-ready');
},
methods: {
getEditor: () => ({
use: mockUse,
registerCiSchema: mockRegisterCiSchema,
}),
},
};
const createComponent = (opts = {}, mountFn = shallowMount) => {
wrapper = mountFn(TextEditor, { wrapper = mountFn(TextEditor, {
propsData: {
ciConfigPath: mockCiConfigPath,
commitSha: mockCommitSha,
projectPath: mockProjectPath,
},
attrs: { attrs: {
value: mockCiYml, value: mockCiYml,
...attrs,
}, },
listeners: { listeners: {
'editor-ready': editorReadyListener, 'editor-ready': editorReadyListener,
}, },
stubs: {
EditorLite: MockEditorLite,
},
...opts,
}); });
}; };
const findEditor = () => wrapper.find(EditorLite); const findEditor = () => wrapper.find(MockEditorLite);
beforeEach(() => {
editorReadyListener = jest.fn();
mockUse = jest.fn();
mockRegisterCiSchema = jest.fn();
it('contains an editor', () => {
createComponent(); createComponent();
});
it('contains an editor', () => {
expect(findEditor().exists()).toBe(true); expect(findEditor().exists()).toBe(true);
}); });
...@@ -32,8 +69,18 @@ describe('~/pipeline_editor/components/text_editor.vue', () => { ...@@ -32,8 +69,18 @@ describe('~/pipeline_editor/components/text_editor.vue', () => {
expect(findEditor().props('value')).toBe(mockCiYml); expect(findEditor().props('value')).toBe(mockCiYml);
}); });
it('editor is configured for .yml', () => { it('editor is configured for the CI config path', () => {
expect(findEditor().props('fileName')).toBe('*.yml'); expect(findEditor().props('fileName')).toBe(mockCiConfigPath);
});
it('editor is configured with syntax highligting', async () => {
expect(mockUse).toHaveBeenCalledTimes(1);
expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1);
expect(mockRegisterCiSchema).toHaveBeenCalledWith({
projectNamespace: mockNamespace,
projectPath: mockProjectName,
ref: mockCommitSha,
});
}); });
it('bubbles up events', () => { it('bubbles up events', () => {
......
import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants';
export const mockProjectPath = 'user1/project1'; export const mockNamespace = 'user1';
export const mockProjectName = 'project1';
export const mockProjectPath = `${mockNamespace}/${mockProjectName}`;
export const mockDefaultBranch = 'master'; export const mockDefaultBranch = 'master';
export const mockNewMergeRequestPath = '/-/merge_requests/new'; export const mockNewMergeRequestPath = '/-/merge_requests/new';
export const mockCommitSha = 'aabbccdd'; export const mockCommitSha = 'aabbccdd';
......
...@@ -42,6 +42,10 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -42,6 +42,10 @@ jest.mock('~/lib/utils/url_utility', () => ({
mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams, mergeUrlParams: jest.requireActual('~/lib/utils/url_utility').mergeUrlParams,
})); }));
const MockEditorLite = {
template: '<div/>',
};
describe('~/pipeline_editor/pipeline_editor_app.vue', () => { describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
let wrapper; let wrapper;
...@@ -87,9 +91,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -87,9 +91,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
GlTabs, GlTabs,
GlButton, GlButton,
CommitForm, CommitForm,
EditorLite: { EditorLite: MockEditorLite,
template: '<div/>',
},
TextEditor, TextEditor,
}, },
mocks: { mocks: {
...@@ -140,6 +142,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -140,6 +142,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
const findTabAt = i => wrapper.findAll(GlTab).at(i); const findTabAt = i => wrapper.findAll(GlTab).at(i);
const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]'); const findVisualizationTab = () => wrapper.find('[data-testid="visualization-tab"]');
const findTextEditor = () => wrapper.find(TextEditor); const findTextEditor = () => wrapper.find(TextEditor);
const findEditorLite = () => wrapper.find(MockEditorLite);
const findCommitForm = () => wrapper.find(CommitForm); const findCommitForm = () => wrapper.find(CommitForm);
const findPipelineGraph = () => wrapper.find(PipelineGraph); const findPipelineGraph = () => wrapper.find(PipelineGraph);
const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon); const findCommitBtnLoadingIcon = () => wrapper.find('[type="submit"]').find(GlLoadingIcon);
...@@ -236,7 +239,14 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -236,7 +239,14 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
it('displays content after the query loads', () => { it('displays content after the query loads', () => {
expect(findLoadingIcon().exists()).toBe(false); expect(findLoadingIcon().exists()).toBe(false);
expect(findTextEditor().attributes('value')).toBe(mockCiYml);
expect(findEditorLite().attributes('value')).toBe(mockCiYml);
expect(findEditorLite().attributes('file-name')).toBe(mockCiConfigPath);
});
it('configures text editor', () => {
expect(findTextEditor().props('commitSha')).toBe(mockCommitSha);
expect(findTextEditor().props('projectPath')).toBe(mockProjectPath);
}); });
describe('commit form', () => { describe('commit form', () => {
...@@ -389,7 +399,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -389,7 +399,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
it('content is restored after cancel is called', async () => { it('content is restored after cancel is called', async () => {
await cancelCommitForm(); await cancelCommitForm();
expect(findTextEditor().attributes('value')).toBe(mockCiYml); expect(findEditorLite().attributes('value')).toBe(mockCiYml);
}); });
}); });
}); });
...@@ -403,7 +413,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { ...@@ -403,7 +413,7 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
await waitForPromises(); await waitForPromises();
expect(findAlert().exists()).toBe(false); expect(findAlert().exists()).toBe(false);
expect(findTextEditor().attributes('value')).toBe(mockCiYml); expect(findEditorLite().attributes('value')).toBe(mockCiYml);
}); });
it('shows a 404 error message', async () => { it('shows a 404 error message', async () => {
......
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