diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d44bfdfb966eb5d4c503e356a7246d87939f514f..e855e304d278c3ca6b70c723d9df5a7f82a4a700 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,17 +1,9 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; 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 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_TYPE_CODE = 'vs.editor.ICodeEditor'; @@ -20,9 +12,31 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; 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__( '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 diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js index 664bcabcf452f81e7dbd0daab6fb17d57f292f42..f6bc62a1c093665ce048c9f90de58d09745ae083 100644 --- a/app/assets/javascripts/editor/source_editor_extension.js +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -12,6 +12,6 @@ export default class EditorExtension { } get api() { - return this.obj.provides(); + return this.obj.provides?.(); } } diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js new file mode 100644 index 0000000000000000000000000000000000000000..e0ca4ea518b50da6f3bb06f8cdbaf5635f121866 --- /dev/null +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -0,0 +1,271 @@ +/** + * @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); + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2ecc0c6543e915b81eccbee03f4b112a2bcfc6f0..1f983b2cb1f618760cb23606a52997cf0e51aed2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -32697,12 +32697,30 @@ msgstr "" msgid "SourceEditor|\"el\" parameter is required for createInstance()" msgstr "" +msgid "SourceEditor|%{name} is not registered." +msgstr "" + msgid "SourceEditor|Extension definition should be either a class or a function" 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." msgstr "" +msgid "SourceEditor|`definition` property is expected on the extension." +msgstr "" + msgid "Sourcegraph" msgstr "" diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..6f7cdf6efb3a5e7c6d073551593c6320509cabb3 --- /dev/null +++ b/spec/frontend/editor/helpers.js @@ -0,0 +1,53 @@ +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', + }; + }, + }; + }, +}; diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js index ebeeae7e42f651ec4743e7a43837fa3aeb1b7732..6f2eb07a043900e40c55cc6f30ce41a07a165dbd 100644 --- a/spec/frontend/editor/source_editor_extension_spec.js +++ b/spec/frontend/editor/source_editor_extension_spec.js @@ -1,37 +1,6 @@ import EditorExtension from '~/editor/source_editor_extension'; import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants'; - -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', - }; - }, - }; -}; +import * as helpers from './helpers'; describe('Editor Extension', () => { const dummyObj = { foo: 'bar' }; @@ -52,16 +21,16 @@ describe('Editor Extension', () => { ); it.each` - definition | setupOptions | expectedName - ${MyClassExtension} | ${undefined} | ${'MyClassExtension'} - ${MyClassExtension} | ${{}} | ${'MyClassExtension'} - ${MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} - ${MyFnExtension} | ${undefined} | ${'MyFnExtension'} - ${MyFnExtension} | ${{}} | ${'MyFnExtension'} - ${MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} - ${MyConstExt} | ${undefined} | ${'MyConstExt'} - ${MyConstExt} | ${{}} | ${'MyConstExt'} - ${MyConstExt} | ${dummyObj} | ${'MyConstExt'} + definition | setupOptions | expectedName + ${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'} + ${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'} + ${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} + ${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'} + ${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'} + ${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} + ${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'} + ${helpers.MyConstExt} | ${{}} | ${'MyConstExt'} + ${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'} `( 'correctly creates extension for definition = $definition and setupOptions = $setupOptions', ({ definition, setupOptions, expectedName }) => { @@ -81,10 +50,10 @@ describe('Editor Extension', () => { describe('api', () => { it.each` - definition | expectedKeys - ${MyClassExtension} | ${['shared', 'classExtMethod']} - ${MyFnExtension} | ${['shared']} - ${MyConstExt} | ${['shared', 'constExtMethod']} + definition | expectedKeys + ${helpers.MyClassExtension} | ${['shared', 'classExtMethod']} + ${helpers.MyFnExtension} | ${['fnExtMethod']} + ${helpers.MyConstExt} | ${['constExtMethod']} `('correctly returns API for $definition', ({ definition, expectedKeys }) => { const extension = new EditorExtension({ definition }); const expectedApi = Object.fromEntries( diff --git a/spec/frontend/editor/source_editor_instance_spec.js b/spec/frontend/editor/source_editor_instance_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..87b20a4ba7380288b99c0eb2287170ea60356bb9 --- /dev/null +++ b/spec/frontend/editor/source_editor_instance_spec.js @@ -0,0 +1,387 @@ +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); + }); + }); + }); +});