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; ...@@ -10,3 +10,12 @@ export const CONTENT_UPDATE_DEBOUNCE = 250;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __(
'Editor Lite instance is required to set up an 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 { ...@@ -84,6 +84,9 @@ export default {
onFileChange() { onFileChange() {
this.$emit('input', this.editor.getValue()); this.$emit('input', this.editor.getValue());
}, },
getEditor() {
return this.editor;
},
}, },
}; };
</script> </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'); ...@@ -7,20 +7,22 @@ jest.mock('~/editor/editor_lite');
describe('Editor Lite component', () => { describe('Editor Lite component', () => {
let wrapper; let wrapper;
const onDidChangeModelContent = jest.fn(); let mockInstance;
const updateModelLanguage = jest.fn();
const getValue = jest.fn();
const setValue = jest.fn();
const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
const fileName = 'lorem.txt'; const fileName = 'lorem.txt';
const fileGlobalId = 'snippet_777'; const fileGlobalId = 'snippet_777';
const createInstanceMock = jest.fn().mockImplementation(() => ({ const createInstanceMock = jest.fn().mockImplementation(() => {
onDidChangeModelContent, mockInstance = {
updateModelLanguage, onDidChangeModelContent: jest.fn(),
getValue, updateModelLanguage: jest.fn(),
setValue, getValue: jest.fn(),
dispose: jest.fn(), setValue: jest.fn(),
})); dispose: jest.fn(),
};
return mockInstance;
});
Editor.mockImplementation(() => { Editor.mockImplementation(() => {
return { return {
createInstance: createInstanceMock, createInstance: createInstanceMock,
...@@ -46,8 +48,8 @@ describe('Editor Lite component', () => { ...@@ -46,8 +48,8 @@ describe('Editor Lite component', () => {
}); });
const triggerChangeContent = val => { const triggerChangeContent = val => {
getValue.mockReturnValue(val); mockInstance.getValue.mockReturnValue(val);
const [cb] = onDidChangeModelContent.mock.calls[0]; const [cb] = mockInstance.onDidChangeModelContent.mock.calls[0];
cb(); cb();
...@@ -92,12 +94,12 @@ describe('Editor Lite component', () => { ...@@ -92,12 +94,12 @@ describe('Editor Lite component', () => {
}); });
return nextTick().then(() => { return nextTick().then(() => {
expect(updateModelLanguage).toHaveBeenCalledWith(newFileName); expect(mockInstance.updateModelLanguage).toHaveBeenCalledWith(newFileName);
}); });
}); });
it('registers callback with editor onChangeContent', () => { 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', () => { it('emits input event when the blob content is changed', () => {
...@@ -117,6 +119,10 @@ describe('Editor Lite component', () => { ...@@ -117,6 +119,10 @@ describe('Editor Lite component', () => {
expect(wrapper.emitted()['editor-ready']).toBeDefined(); 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', () => { describe('reaction to the value update', () => {
it('reacts to the changes in the passed value', async () => { it('reacts to the changes in the passed value', async () => {
const newValue = 'New Value'; const newValue = 'New Value';
...@@ -126,7 +132,7 @@ describe('Editor Lite component', () => { ...@@ -126,7 +132,7 @@ describe('Editor Lite component', () => {
}); });
await nextTick(); 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 () => { 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', () => { ...@@ -137,7 +143,7 @@ describe('Editor Lite component', () => {
}); });
await nextTick(); 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