Commit f3ae0269 authored by Denys Mishunov's avatar Denys Mishunov

Merge branch '263145-editor-ci-static-validation-extension' into 'master'

Define CI static validation extension for Editor Lite

See merge request gitlab-org/gitlab!50191
parents 8f71ec01 3b196417
...@@ -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