Commit 3b196417 authored by Miguel Rincon's avatar Miguel Rincon

Define CI static validation extension

This change adds a new schema validation extension for GitLab CI YML
files for EditorLite.

This extension can be used to provide syntax validation on GitLab CI
configuration files by providing project information.
parent 3d210bc9
......@@ -10,3 +10,12 @@ export const CONTENT_UPDATE_DEBOUNCE = 250;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __(
'Editor Lite instance is required to set up an extension.',
);
//
// EXTENSIONS' CONSTANTS
//
// For CI config schemas the filename must match
// '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml';
import Api from '~/api';
import { registerSchema } from '~/ide/utils';
import { EditorLiteExtension } from './editor_lite_extension_base';
import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from './constants';
export class CiSchemaExtension extends EditorLiteExtension {
/**
* Registers a syntax schema to the editor based on project
* identifier and commit.
*
* The schema is added to the file that is currently edited
* in the editor.
*
* @param {Object} opts
* @param {String} opts.projectNamespace
* @param {String} opts.projectPath
* @param {String?} opts.ref - Current ref. Defaults to master
*/
registerCiSchema({ projectNamespace, projectPath, ref = 'master' } = {}) {
const ciSchemaUri = Api.buildUrl(Api.projectFileSchemaPath)
.replace(':namespace_path', projectNamespace)
.replace(':project_path', projectPath)
.replace(':ref', ref)
.replace(':filename', EXTENSION_CI_SCHEMA_FILE_NAME_MATCH);
const modelFileName = this.getModel()
.uri.path.split('/')
.pop();
registerSchema({
uri: ciSchemaUri,
fileMatch: [modelFileName],
});
}
}
......@@ -84,6 +84,9 @@ export default {
onFileChange() {
this.$emit('input', this.editor.getValue());
},
getEditor() {
return this.editor;
},
},
};
</script>
......
import { languages } from 'monaco-editor';
import EditorLite from '~/editor/editor_lite';
import { CiSchemaExtension } from '~/editor/editor_ci_schema_ext';
import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '~/editor/constants';
describe('~/editor/editor_ci_config_ext', () => {
const defaultBlobPath = '.gitlab-ci.yml';
let editor;
let instance;
let editorEl;
const createMockEditor = ({ blobPath = defaultBlobPath } = {}) => {
setFixtures('<div id="editor"></div>');
editorEl = document.getElementById('editor');
editor = new EditorLite();
instance = editor.createInstance({
el: editorEl,
blobPath,
blobContent: '',
});
instance.use(new CiSchemaExtension());
};
beforeEach(() => {
createMockEditor();
});
afterEach(() => {
instance.dispose();
editorEl.remove();
});
describe('registerCiSchema', () => {
beforeEach(() => {
jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions');
});
describe('register validations options with monaco for yaml language', () => {
const mockProjectNamespace = 'namespace1';
const mockProjectPath = 'project1';
const getConfiguredYmlSchema = () => {
return languages.yaml.yamlDefaults.setDiagnosticsOptions.mock.calls[0][0].schemas[0];
};
it('with expected basic validation configuration', () => {
instance.registerCiSchema({
projectNamespace: mockProjectNamespace,
projectPath: mockProjectPath,
});
const expectedOptions = {
validate: true,
enableSchemaRequest: true,
hover: true,
completion: true,
};
expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledTimes(1);
expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith(
expect.objectContaining(expectedOptions),
);
});
it('with an schema uri that contains project and ref', () => {
const mockRef = 'AABBCCDD';
instance.registerCiSchema({
projectNamespace: mockProjectNamespace,
projectPath: mockProjectPath,
ref: mockRef,
});
expect(getConfiguredYmlSchema()).toEqual({
uri: `/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`,
fileMatch: [defaultBlobPath],
});
});
it('with an alternative file name match', () => {
createMockEditor({ blobPath: 'dir1/dir2/another-ci-filename.yml' });
instance.registerCiSchema({
projectNamespace: mockProjectNamespace,
projectPath: mockProjectPath,
});
expect(getConfiguredYmlSchema()).toEqual({
uri: `/${mockProjectNamespace}/${mockProjectPath}/-/schema/master/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`,
fileMatch: ['another-ci-filename.yml'],
});
});
});
});
});
......@@ -7,20 +7,22 @@ jest.mock('~/editor/editor_lite');
describe('Editor Lite component', () => {
let wrapper;
const onDidChangeModelContent = jest.fn();
const updateModelLanguage = jest.fn();
const getValue = jest.fn();
const setValue = jest.fn();
let mockInstance;
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt';
const fileGlobalId = 'snippet_777';
const createInstanceMock = jest.fn().mockImplementation(() => ({
onDidChangeModelContent,
updateModelLanguage,
getValue,
setValue,
dispose: jest.fn(),
}));
const createInstanceMock = jest.fn().mockImplementation(() => {
mockInstance = {
onDidChangeModelContent: jest.fn(),
updateModelLanguage: jest.fn(),
getValue: jest.fn(),
setValue: jest.fn(),
dispose: jest.fn(),
};
return mockInstance;
});
Editor.mockImplementation(() => {
return {
createInstance: createInstanceMock,
......@@ -46,8 +48,8 @@ describe('Editor Lite component', () => {
});
const triggerChangeContent = val => {
getValue.mockReturnValue(val);
const [cb] = onDidChangeModelContent.mock.calls[0];
mockInstance.getValue.mockReturnValue(val);
const [cb] = mockInstance.onDidChangeModelContent.mock.calls[0];
cb();
......@@ -92,12 +94,12 @@ describe('Editor Lite component', () => {
});
return nextTick().then(() => {
expect(updateModelLanguage).toHaveBeenCalledWith(newFileName);
expect(mockInstance.updateModelLanguage).toHaveBeenCalledWith(newFileName);
});
});
it('registers callback with editor onChangeContent', () => {
expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
expect(mockInstance.onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
});
it('emits input event when the blob content is changed', () => {
......@@ -117,6 +119,10 @@ describe('Editor Lite component', () => {
expect(wrapper.emitted()['editor-ready']).toBeDefined();
});
it('component API `getEditor()` returns the editor instance', () => {
expect(wrapper.vm.getEditor()).toBe(mockInstance);
});
describe('reaction to the value update', () => {
it('reacts to the changes in the passed value', async () => {
const newValue = 'New Value';
......@@ -126,7 +132,7 @@ describe('Editor Lite component', () => {
});
await nextTick();
expect(setValue).toHaveBeenCalledWith(newValue);
expect(mockInstance.setValue).toHaveBeenCalledWith(newValue);
});
it("does not update value if the passed one is exactly the same as the editor's content", async () => {
......@@ -137,7 +143,7 @@ describe('Editor Lite component', () => {
});
await nextTick();
expect(setValue).not.toHaveBeenCalled();
expect(mockInstance.setValue).not.toHaveBeenCalled();
});
});
});
......
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