Commit 5c9d969b authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '292943-se-instance-module' into 'master'

Introduced the Source Editor Instance module

See merge request gitlab-org/gitlab!74566
parents b4c0fa17 e993bcdb
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'SourceEditor|"el" parameter is required for createInstance()',
);
export const URI_PREFIX = 'gitlab'; export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
'SourceEditor|Source Editor instance is required to set up an extension.',
);
export const EDITOR_READY_EVENT = 'editor-ready'; export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
...@@ -20,9 +12,31 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; ...@@ -20,9 +12,31 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'SourceEditor|"el" parameter is required for createInstance()',
);
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
'SourceEditor|Source Editor instance is required to set up an extension.',
);
export const EDITOR_EXTENSION_DEFINITION_ERROR = s__( export const EDITOR_EXTENSION_DEFINITION_ERROR = s__(
'SourceEditor|Extension definition should be either a class or a function', 'SourceEditor|Extension definition should be either a class or a function',
); );
export const EDITOR_EXTENSION_NO_DEFINITION_ERROR = s__(
'SourceEditor|`definition` property is expected on the extension.',
);
export const EDITOR_EXTENSION_DEFINITION_TYPE_ERROR = s__(
'SourceEditor|Extension definition should be either class, function, or an Array of definitions.',
);
export const EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR = s__(
'SourceEditor|No extension for unuse has been specified.',
);
export const EDITOR_EXTENSION_NOT_REGISTERED_ERROR = s__('SourceEditor|%{name} is not registered.');
export const EDITOR_EXTENSION_NAMING_CONFLICT_ERROR = s__(
'SourceEditor|Name conflict for "%{prop}()" method.',
);
export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__(
'SourceEditor|Extensions Store is required to check for an extension.',
);
// //
// EXTENSIONS' CONSTANTS // EXTENSIONS' CONSTANTS
......
...@@ -12,6 +12,6 @@ export default class EditorExtension { ...@@ -12,6 +12,6 @@ export default class EditorExtension {
} }
get api() { get api() {
return this.obj.provides(); return this.obj.provides?.();
} }
} }
/**
* @module source_editor_instance
*/
/**
* A Source Editor Extension definition
* @typedef {Object} SourceEditorExtensionDefinition
* @property {Object} definition
* @property {Object} setupOptions
*/
/**
* A Source Editor Extension
* @typedef {Object} SourceEditorExtension
* @property {Object} obj
* @property {string} name
* @property {Object} api
*/
import { isEqual } from 'lodash';
import { editor as monacoEditor } from 'monaco-editor';
import { getBlobLanguage } from '~/editor/utils';
import { logError } from '~/lib/logger';
import { sprintf } from '~/locale';
import EditorExtension from './source_editor_extension';
import {
EDITOR_EXTENSION_DEFINITION_TYPE_ERROR,
EDITOR_EXTENSION_NAMING_CONFLICT_ERROR,
EDITOR_EXTENSION_NO_DEFINITION_ERROR,
EDITOR_EXTENSION_NOT_REGISTERED_ERROR,
EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR,
EDITOR_EXTENSION_STORE_IS_MISSING_ERROR,
} from './constants';
const utils = {
removeExtFromMethod: (method, extensionName, container) => {
if (!container) {
return;
}
if (Object.prototype.hasOwnProperty.call(container, method)) {
// eslint-disable-next-line no-param-reassign
delete container[method];
}
},
getStoredExtension: (extensionsStore, name) => {
if (!extensionsStore) {
logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR);
return undefined;
}
return extensionsStore.get(name);
},
};
/** Class representing a Source Editor Instance */
export default class EditorInstance {
/**
* Create a Source Editor Instance
* @param {Object} rootInstance - Monaco instance to build on top of
* @param {Map} extensionsStore - The global registry for the extension instances
* @returns {Object} - A Proxy returning props/methods from either registered extensions, or Source Editor instance, or underlying Monaco instance
*/
constructor(rootInstance = {}, extensionsStore = new Map()) {
/** The methods provided by extensions. */
this.methods = {};
const seInstance = this;
const getHandler = {
get(target, prop, receiver) {
const methodExtension =
Object.prototype.hasOwnProperty.call(seInstance.methods, prop) &&
seInstance.methods[prop];
if (methodExtension) {
const extension = extensionsStore.get(methodExtension);
return (...args) => {
return extension.api[prop].call(seInstance, ...args, receiver);
};
}
return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
},
set(target, prop, value) {
Object.assign(seInstance, {
[prop]: value,
});
return true;
},
};
const instProxy = new Proxy(rootInstance, getHandler);
/**
* Main entry point to apply an extension to the instance
* @param {SourceEditorExtensionDefinition}
*/
this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension);
/**
* Main entry point to un-use an extension and remove it from the instance
* @param {SourceEditorExtension}
*/
this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension);
return instProxy;
}
/**
* A private dispatcher function for both `use` and `unuse`
* @param {Map} extensionsStore - The global registry for the extension instances
* @param {Function} fn - A function to route to. Either `this.useExtension` or `this.unuseExtension`
* @param {SourceEditorExtensionDefinition[]} extensions - The extensions to use/unuse.
* @returns {Function}
*/
static useUnuse(extensionsStore, fn, extensions) {
if (Array.isArray(extensions)) {
/**
* We cut short if the Array is empty and let the destination function to throw
* Otherwise, we run the destination function on every entry of the Array
*/
return extensions.length
? extensions.map(fn.bind(this, extensionsStore))
: fn.call(this, extensionsStore);
}
return fn.call(this, extensionsStore, extensions);
}
//
// REGISTERING NEW EXTENSION
//
/**
* Run all registrations when using an extension
* @param {Map} extensionsStore - The global registry for the extension instances
* @param {SourceEditorExtensionDefinition} extension - The extension definition to use.
* @returns {EditorExtension|*}
*/
useExtension(extensionsStore, extension = {}) {
const { definition } = extension;
if (!definition) {
throw new Error(EDITOR_EXTENSION_NO_DEFINITION_ERROR);
}
if (typeof definition !== 'function') {
throw new Error(EDITOR_EXTENSION_DEFINITION_TYPE_ERROR);
}
// Existing Extension Path
const existingExt = utils.getStoredExtension(extensionsStore, definition.name);
if (existingExt) {
if (isEqual(extension.setupOptions, existingExt.setupOptions)) {
return existingExt;
}
this.unuseExtension(extensionsStore, existingExt);
}
// New Extension Path
const extensionInstance = new EditorExtension(extension);
const { setupOptions, obj: extensionObj } = extensionInstance;
if (extensionObj.onSetup) {
extensionObj.onSetup(setupOptions, this);
}
if (extensionsStore) {
this.registerExtension(extensionInstance, extensionsStore);
}
this.registerExtensionMethods(extensionInstance);
return extensionInstance;
}
/**
* Register extension in the global extensions store
* @param {SourceEditorExtension} extension - Instance of Source Editor extension
* @param {Map} extensionsStore - The global registry for the extension instances
*/
registerExtension(extension, extensionsStore) {
const { name } = extension;
const hasExtensionRegistered =
extensionsStore.has(name) &&
isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions);
if (hasExtensionRegistered) {
return;
}
extensionsStore.set(name, extension);
const { obj: extensionObj } = extension;
if (extensionObj.onUse) {
extensionObj.onUse(this);
}
}
/**
* Register extension methods in the registry on the instance
* @param {SourceEditorExtension} extension - Instance of Source Editor extension
*/
registerExtensionMethods(extension) {
const { api, name } = extension;
if (!api) {
return;
}
Object.keys(api).forEach((prop) => {
if (this[prop]) {
logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop }));
} else {
this.methods[prop] = name;
}
}, this);
}
//
// UNREGISTERING AN EXTENSION
//
/**
* Unregister extension with the cleanup
* @param {Map} extensionsStore - The global registry for the extension instances
* @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
*/
unuseExtension(extensionsStore, extension) {
if (!extension) {
throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR);
}
const { name } = extension;
const existingExt = utils.getStoredExtension(extensionsStore, name);
if (!existingExt) {
throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name }));
}
const { obj: extensionObj } = existingExt;
if (extensionObj.onBeforeUnuse) {
extensionObj.onBeforeUnuse(this);
}
this.unregisterExtensionMethods(existingExt);
if (extensionObj.onUnuse) {
extensionObj.onUnuse(this);
}
}
/**
* Remove all methods associated with this extension from the registry on the instance
* @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use
*/
unregisterExtensionMethods(extension) {
const { api, name } = extension;
if (!api) {
return;
}
Object.keys(api).forEach((method) => {
utils.removeExtFromMethod(method, name, this.methods);
});
}
/**
* PUBLIC API OF AN INSTANCE
*/
/**
* Updates model language based on the path
* @param {String} path - blob path
*/
updateModelLanguage(path) {
const lang = getBlobLanguage(path);
const model = this.getModel();
// return monacoEditor.setModelLanguage(model, lang);
monacoEditor.setModelLanguage(model, lang);
}
/**
* Get the methods returned by extensions.
* @returns {Array}
*/
get extensionsAPI() {
return Object.keys(this.methods);
}
}
...@@ -32697,12 +32697,30 @@ msgstr "" ...@@ -32697,12 +32697,30 @@ msgstr ""
msgid "SourceEditor|\"el\" parameter is required for createInstance()" msgid "SourceEditor|\"el\" parameter is required for createInstance()"
msgstr "" msgstr ""
msgid "SourceEditor|%{name} is not registered."
msgstr ""
msgid "SourceEditor|Extension definition should be either a class or a function" msgid "SourceEditor|Extension definition should be either a class or a function"
msgstr "" msgstr ""
msgid "SourceEditor|Extension definition should be either class, function, or an Array of definitions."
msgstr ""
msgid "SourceEditor|Extensions Store is required to check for an extension."
msgstr ""
msgid "SourceEditor|Name conflict for \"%{prop}()\" method."
msgstr ""
msgid "SourceEditor|No extension for unuse has been specified."
msgstr ""
msgid "SourceEditor|Source Editor instance is required to set up an extension." msgid "SourceEditor|Source Editor instance is required to set up an extension."
msgstr "" msgstr ""
msgid "SourceEditor|`definition` property is expected on the extension."
msgstr ""
msgid "Sourcegraph" msgid "Sourcegraph"
msgstr "" msgstr ""
......
export class MyClassExtension {
// eslint-disable-next-line class-methods-use-this
provides() {
return {
shared: () => 'extension',
classExtMethod: () => 'class own method',
};
}
}
export function MyFnExtension() {
return {
fnExtMethod: () => 'fn own method',
provides: () => {
return {
fnExtMethod: () => 'class own method',
};
},
};
}
export const MyConstExt = () => {
return {
provides: () => {
return {
constExtMethod: () => 'const own method',
};
},
};
};
export const conflictingExtensions = {
WithInstanceExt: () => {
return {
provides: () => {
return {
use: () => 'A conflict with instance',
ownMethod: () => 'Non-conflicting method',
};
},
};
},
WithAnotherExt: () => {
return {
provides: () => {
return {
shared: () => 'A conflict with extension',
ownMethod: () => 'Non-conflicting method',
};
},
};
},
};
import EditorExtension from '~/editor/source_editor_extension'; import EditorExtension from '~/editor/source_editor_extension';
import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants'; import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants';
import * as helpers from './helpers';
class MyClassExtension {
// eslint-disable-next-line class-methods-use-this
provides() {
return {
shared: () => 'extension',
classExtMethod: () => 'class own method',
};
}
}
function MyFnExtension() {
return {
fnExtMethod: () => 'fn own method',
provides: () => {
return {
shared: () => 'extension',
};
},
};
}
const MyConstExt = () => {
return {
provides: () => {
return {
shared: () => 'extension',
constExtMethod: () => 'const own method',
};
},
};
};
describe('Editor Extension', () => { describe('Editor Extension', () => {
const dummyObj = { foo: 'bar' }; const dummyObj = { foo: 'bar' };
...@@ -53,15 +22,15 @@ describe('Editor Extension', () => { ...@@ -53,15 +22,15 @@ describe('Editor Extension', () => {
it.each` it.each`
definition | setupOptions | expectedName definition | setupOptions | expectedName
${MyClassExtension} | ${undefined} | ${'MyClassExtension'} ${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'}
${MyClassExtension} | ${{}} | ${'MyClassExtension'} ${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'}
${MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} ${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'}
${MyFnExtension} | ${undefined} | ${'MyFnExtension'} ${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'}
${MyFnExtension} | ${{}} | ${'MyFnExtension'} ${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'}
${MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} ${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'}
${MyConstExt} | ${undefined} | ${'MyConstExt'} ${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'}
${MyConstExt} | ${{}} | ${'MyConstExt'} ${helpers.MyConstExt} | ${{}} | ${'MyConstExt'}
${MyConstExt} | ${dummyObj} | ${'MyConstExt'} ${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'}
`( `(
'correctly creates extension for definition = $definition and setupOptions = $setupOptions', 'correctly creates extension for definition = $definition and setupOptions = $setupOptions',
({ definition, setupOptions, expectedName }) => { ({ definition, setupOptions, expectedName }) => {
...@@ -82,9 +51,9 @@ describe('Editor Extension', () => { ...@@ -82,9 +51,9 @@ describe('Editor Extension', () => {
describe('api', () => { describe('api', () => {
it.each` it.each`
definition | expectedKeys definition | expectedKeys
${MyClassExtension} | ${['shared', 'classExtMethod']} ${helpers.MyClassExtension} | ${['shared', 'classExtMethod']}
${MyFnExtension} | ${['shared']} ${helpers.MyFnExtension} | ${['fnExtMethod']}
${MyConstExt} | ${['shared', 'constExtMethod']} ${helpers.MyConstExt} | ${['constExtMethod']}
`('correctly returns API for $definition', ({ definition, expectedKeys }) => { `('correctly returns API for $definition', ({ definition, expectedKeys }) => {
const extension = new EditorExtension({ definition }); const extension = new EditorExtension({ definition });
const expectedApi = Object.fromEntries( const expectedApi = Object.fromEntries(
......
import { editor as monacoEditor } from 'monaco-editor';
import {
EDITOR_EXTENSION_NAMING_CONFLICT_ERROR,
EDITOR_EXTENSION_NO_DEFINITION_ERROR,
EDITOR_EXTENSION_DEFINITION_TYPE_ERROR,
EDITOR_EXTENSION_NOT_REGISTERED_ERROR,
EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR,
} from '~/editor/constants';
import Instance from '~/editor/source_editor_instance';
import { sprintf } from '~/locale';
import { MyClassExtension, conflictingExtensions, MyFnExtension, MyConstExt } from './helpers';
describe('Source Editor Instance', () => {
let seInstance;
const defSetupOptions = { foo: 'bar' };
const fullExtensionsArray = [
{ definition: MyClassExtension },
{ definition: MyFnExtension },
{ definition: MyConstExt },
];
const fullExtensionsArrayWithOptions = [
{ definition: MyClassExtension, setupOptions: defSetupOptions },
{ definition: MyFnExtension, setupOptions: defSetupOptions },
{ definition: MyConstExt, setupOptions: defSetupOptions },
];
const fooFn = jest.fn();
class DummyExt {
// eslint-disable-next-line class-methods-use-this
provides() {
return {
fooFn,
};
}
}
afterEach(() => {
seInstance = undefined;
});
it('sets up the registry for the methods coming from extensions', () => {
seInstance = new Instance();
expect(seInstance.methods).toBeDefined();
seInstance.use({ definition: MyClassExtension });
expect(seInstance.methods).toEqual({
shared: 'MyClassExtension',
classExtMethod: 'MyClassExtension',
});
seInstance.use({ definition: MyFnExtension });
expect(seInstance.methods).toEqual({
shared: 'MyClassExtension',
classExtMethod: 'MyClassExtension',
fnExtMethod: 'MyFnExtension',
});
});
describe('proxy', () => {
it('returns prop from an extension if extension provides it', () => {
seInstance = new Instance();
seInstance.use({ definition: DummyExt });
expect(fooFn).not.toHaveBeenCalled();
seInstance.fooFn();
expect(fooFn).toHaveBeenCalled();
});
it('returns props from SE instance itself if no extension provides the prop', () => {
seInstance = new Instance({
use: fooFn,
});
jest.spyOn(seInstance, 'use').mockImplementation(() => {});
expect(seInstance.use).not.toHaveBeenCalled();
expect(fooFn).not.toHaveBeenCalled();
seInstance.use();
expect(seInstance.use).toHaveBeenCalled();
expect(fooFn).not.toHaveBeenCalled();
});
it('returns props from Monaco instance when the prop does not exist on the SE instance', () => {
seInstance = new Instance({
fooFn,
});
expect(fooFn).not.toHaveBeenCalled();
seInstance.fooFn();
expect(fooFn).toHaveBeenCalled();
});
});
describe('public API', () => {
it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => {
seInstance = new Instance();
expect(seInstance[method]).toBeDefined();
});
describe('use', () => {
it('extends the SE instance with methods provided by an extension', () => {
seInstance = new Instance();
seInstance.use({ definition: DummyExt });
expect(fooFn).not.toHaveBeenCalled();
seInstance.fooFn();
expect(fooFn).toHaveBeenCalled();
});
it.each`
extensions | expectedProps
${{ definition: MyClassExtension }} | ${['shared', 'classExtMethod']}
${{ definition: MyFnExtension }} | ${['fnExtMethod']}
${{ definition: MyConstExt }} | ${['constExtMethod']}
${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
`(
'Should register $expectedProps when extension is "$extensions"',
({ extensions, expectedProps }) => {
seInstance = new Instance();
expect(seInstance.extensionsAPI).toHaveLength(0);
seInstance.use(extensions);
expect(seInstance.extensionsAPI).toEqual(expectedProps);
},
);
it.each`
definition | preInstalledExtDefinition | expectedErrorProp
${conflictingExtensions.WithInstanceExt} | ${MyClassExtension} | ${'use'}
${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'}
${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined}
${conflictingExtensions.WithAnotherExt} | ${MyClassExtension} | ${'shared'}
${MyClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'}
`(
'logs the naming conflict error when registering $definition',
({ definition, preInstalledExtDefinition, expectedErrorProp }) => {
seInstance = new Instance();
jest.spyOn(console, 'error').mockImplementation(() => {});
if (preInstalledExtDefinition) {
seInstance.use({ definition: preInstalledExtDefinition });
// eslint-disable-next-line no-console
expect(console.error).not.toHaveBeenCalled();
}
seInstance.use({ definition });
if (expectedErrorProp) {
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining(
sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop: expectedErrorProp }),
),
);
} else {
// eslint-disable-next-line no-console
expect(console.error).not.toHaveBeenCalled();
}
},
);
it.each`
extensions | thrownError
${''} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR}
${undefined} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR}
${{}} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR}
${{ foo: 'bar' }} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR}
${{ definition: '' }} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR}
${{ definition: undefined }} | ${EDITOR_EXTENSION_NO_DEFINITION_ERROR}
${{ definition: [] }} | ${EDITOR_EXTENSION_DEFINITION_TYPE_ERROR}
${{ definition: {} }} | ${EDITOR_EXTENSION_DEFINITION_TYPE_ERROR}
${{ definition: { foo: 'bar' } }} | ${EDITOR_EXTENSION_DEFINITION_TYPE_ERROR}
`(
'Should throw $thrownError when extension is "$extensions"',
({ extensions, thrownError }) => {
seInstance = new Instance();
const useExtension = () => {
seInstance.use(extensions);
};
expect(useExtension).toThrowError(thrownError);
},
);
describe('global extensions registry', () => {
let extensionStore;
beforeEach(() => {
extensionStore = new Map();
seInstance = new Instance({}, extensionStore);
});
it('stores _instances_ of the used extensions in a global registry', () => {
const extension = seInstance.use({ definition: MyClassExtension });
expect(extensionStore.size).toBe(1);
expect(extensionStore.entries().next().value).toEqual(['MyClassExtension', extension]);
});
it('does not duplicate entries in the registry', () => {
jest.spyOn(extensionStore, 'set');
const extension1 = seInstance.use({ definition: MyClassExtension });
seInstance.use({ definition: MyClassExtension });
expect(extensionStore.set).toHaveBeenCalledTimes(1);
expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1);
});
it.each`
desc | currentSetupOptions | newSetupOptions | expectedCallTimes
${'updates'} | ${undefined} | ${defSetupOptions} | ${2}
${'updates'} | ${defSetupOptions} | ${undefined} | ${2}
${'updates'} | ${{ foo: 'bar' }} | ${{ foo: 'new' }} | ${2}
${'does not update'} | ${undefined} | ${undefined} | ${1}
${'does not update'} | ${{}} | ${{}} | ${1}
${'does not update'} | ${defSetupOptions} | ${defSetupOptions} | ${1}
`(
'$desc the extensions entry when setupOptions "$currentSetupOptions" get changed to "$newSetupOptions"',
({ currentSetupOptions, newSetupOptions, expectedCallTimes }) => {
jest.spyOn(extensionStore, 'set');
const extension1 = seInstance.use({
definition: MyClassExtension,
setupOptions: currentSetupOptions,
});
const extension2 = seInstance.use({
definition: MyClassExtension,
setupOptions: newSetupOptions,
});
expect(extensionStore.size).toBe(1);
expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes);
if (expectedCallTimes > 1) {
expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension2);
} else {
expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1);
}
},
);
});
});
describe('unuse', () => {
it.each`
unuseExtension | thrownError
${undefined} | ${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}
${''} | ${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}
${{}} | ${sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name: '' })}
${[]} | ${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}
`(
`Should throw "${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}" when extension is "$unuseExtension"`,
({ unuseExtension, thrownError }) => {
seInstance = new Instance();
const unuse = () => {
seInstance.unuse(unuseExtension);
};
expect(unuse).toThrowError(thrownError);
},
);
it.each`
initExtensions | unuseExtensionIndex | remainingAPI
${{ definition: MyClassExtension }} | ${0} | ${[]}
${{ definition: MyFnExtension }} | ${0} | ${[]}
${{ definition: MyConstExt }} | ${0} | ${[]}
${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']}
${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']}
${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']}
`(
'un-registers properties introduced by single extension $unuseExtension',
({ initExtensions, unuseExtensionIndex, remainingAPI }) => {
seInstance = new Instance();
const extensions = seInstance.use(initExtensions);
if (Array.isArray(initExtensions)) {
seInstance.unuse(extensions[unuseExtensionIndex]);
} else {
seInstance.unuse(extensions);
}
expect(seInstance.extensionsAPI).toEqual(remainingAPI);
},
);
it.each`
unuseExtensionIndex | remainingAPI
${[0, 1]} | ${['constExtMethod']}
${[0, 2]} | ${['fnExtMethod']}
${[1, 2]} | ${['shared', 'classExtMethod']}
`(
'un-registers properties introduced by multiple extensions $unuseExtension',
({ unuseExtensionIndex, remainingAPI }) => {
seInstance = new Instance();
const extensions = seInstance.use(fullExtensionsArray);
const extensionsToUnuse = extensions.filter((ext, index) =>
unuseExtensionIndex.includes(index),
);
seInstance.unuse(extensionsToUnuse);
expect(seInstance.extensionsAPI).toEqual(remainingAPI);
},
);
it('it does not remove entry from the global registry to keep for potential future re-use', () => {
const extensionStore = new Map();
seInstance = new Instance({}, extensionStore);
const extensions = seInstance.use(fullExtensionsArray);
const verifyExpectations = () => {
const entries = extensionStore.entries();
const mockExtensions = ['MyClassExtension', 'MyFnExtension', 'MyConstExt'];
expect(extensionStore.size).toBe(mockExtensions.length);
mockExtensions.forEach((ext, index) => {
expect(entries.next().value).toEqual([ext, extensions[index]]);
});
};
verifyExpectations();
seInstance.unuse(extensions);
verifyExpectations();
});
});
describe('updateModelLanguage', () => {
let instanceModel;
beforeEach(() => {
instanceModel = monacoEditor.createModel('');
seInstance = new Instance({
getModel: () => instanceModel,
});
});
it.each`
path | expectedLanguage
${'foo.js'} | ${'javascript'}
${'foo.md'} | ${'markdown'}
${'foo.rb'} | ${'ruby'}
${''} | ${'plaintext'}
${undefined} | ${'plaintext'}
${'test.nonexistingext'} | ${'plaintext'}
`(
'changes language of an attached model to "$expectedLanguage" when filepath is "$path"',
({ path, expectedLanguage }) => {
seInstance.updateModelLanguage(path);
expect(instanceModel.getLanguageIdentifier().language).toBe(expectedLanguage);
},
);
});
describe('extensions life-cycle callbacks', () => {
const onSetup = jest.fn().mockImplementation(() => {});
const onUse = jest.fn().mockImplementation(() => {});
const onBeforeUnuse = jest.fn().mockImplementation(() => {});
const onUnuse = jest.fn().mockImplementation(() => {});
const MyFullExtWithCallbacks = () => {
return {
onSetup,
onUse,
onBeforeUnuse,
onUnuse,
};
};
it('passes correct arguments to callback fns when using an extension', () => {
seInstance = new Instance();
seInstance.use({
definition: MyFullExtWithCallbacks,
setupOptions: defSetupOptions,
});
expect(onSetup).toHaveBeenCalledWith(defSetupOptions, seInstance);
expect(onUse).toHaveBeenCalledWith(seInstance);
});
it('passes correct arguments to callback fns when un-using an extension', () => {
seInstance = new Instance();
const extension = seInstance.use({
definition: MyFullExtWithCallbacks,
setupOptions: defSetupOptions,
});
seInstance.unuse(extension);
expect(onBeforeUnuse).toHaveBeenCalledWith(seInstance);
expect(onUnuse).toHaveBeenCalledWith(seInstance);
});
});
});
});
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