468 lines
18 KiB
JavaScript
468 lines
18 KiB
JavaScript
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 SourceEditorInstance from '~/editor/source_editor_instance';
|
|
import { sprintf } from '~/locale';
|
|
import {
|
|
SEClassExtension,
|
|
conflictingExtensions,
|
|
SEFnExtension,
|
|
SEConstExt,
|
|
SEWithSetupExt,
|
|
} from './helpers';
|
|
|
|
describe('Source Editor Instance', () => {
|
|
let seInstance;
|
|
|
|
const defSetupOptions = { foo: 'bar' };
|
|
const fullExtensionsArray = [
|
|
{ definition: SEClassExtension },
|
|
{ definition: SEFnExtension },
|
|
{ definition: SEConstExt },
|
|
];
|
|
const fullExtensionsArrayWithOptions = [
|
|
{ definition: SEClassExtension, setupOptions: defSetupOptions },
|
|
{ definition: SEFnExtension, setupOptions: defSetupOptions },
|
|
{ definition: SEConstExt, setupOptions: defSetupOptions },
|
|
];
|
|
|
|
const fooFn = jest.fn();
|
|
const fooProp = 'foo';
|
|
class DummyExt {
|
|
// eslint-disable-next-line class-methods-use-this
|
|
get extensionName() {
|
|
return 'DummyExt';
|
|
}
|
|
// eslint-disable-next-line class-methods-use-this
|
|
provides() {
|
|
return {
|
|
fooFn,
|
|
fooProp,
|
|
};
|
|
}
|
|
}
|
|
|
|
afterEach(() => {
|
|
seInstance = undefined;
|
|
});
|
|
|
|
it('sets up the registry for the methods coming from extensions', () => {
|
|
seInstance = new SourceEditorInstance();
|
|
expect(seInstance.methods).toBeDefined();
|
|
|
|
seInstance.use({ definition: SEClassExtension });
|
|
expect(seInstance.methods).toEqual({
|
|
shared: 'SEClassExtension',
|
|
classExtMethod: 'SEClassExtension',
|
|
});
|
|
|
|
seInstance.use({ definition: SEFnExtension });
|
|
expect(seInstance.methods).toEqual({
|
|
shared: 'SEClassExtension',
|
|
classExtMethod: 'SEClassExtension',
|
|
fnExtMethod: 'SEFnExtension',
|
|
});
|
|
});
|
|
|
|
describe('proxy', () => {
|
|
it('returns a method from an extension if extension provides it', () => {
|
|
seInstance = new SourceEditorInstance();
|
|
seInstance.use({ definition: DummyExt });
|
|
|
|
expect(fooFn).not.toHaveBeenCalled();
|
|
seInstance.fooFn();
|
|
expect(fooFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns a prop from an extension if extension provides it', () => {
|
|
seInstance = new SourceEditorInstance();
|
|
seInstance.use({ definition: DummyExt });
|
|
|
|
expect(seInstance.fooProp).toBe('foo');
|
|
});
|
|
|
|
it.each`
|
|
stringPropToPass | objPropToPass | setupOptions
|
|
${undefined} | ${undefined} | ${undefined}
|
|
${'prop'} | ${undefined} | ${undefined}
|
|
${'prop'} | ${[]} | ${undefined}
|
|
${'prop'} | ${{}} | ${undefined}
|
|
${'prop'} | ${{ alpha: 'beta' }} | ${undefined}
|
|
${'prop'} | ${{ alpha: 'beta' }} | ${defSetupOptions}
|
|
${'prop'} | ${undefined} | ${defSetupOptions}
|
|
${undefined} | ${undefined} | ${defSetupOptions}
|
|
${''} | ${{}} | ${defSetupOptions}
|
|
`(
|
|
'correctly passes arguments ("$stringPropToPass", "$objPropToPass") and instance (with "$setupOptions" setupOptions) to extension methods',
|
|
({ stringPropToPass, objPropToPass, setupOptions }) => {
|
|
seInstance = new SourceEditorInstance();
|
|
seInstance.use({ definition: SEWithSetupExt, setupOptions });
|
|
|
|
const [stringProp, objProp, instance] = seInstance.returnInstanceAndProps(
|
|
stringPropToPass,
|
|
objPropToPass,
|
|
);
|
|
const expectedObjProps = objPropToPass || {};
|
|
|
|
expect(instance).toBe(seInstance);
|
|
expect(stringProp).toBe(stringPropToPass);
|
|
expect(objProp).toEqual(expectedObjProps);
|
|
if (setupOptions) {
|
|
Object.keys(setupOptions).forEach((key) => {
|
|
expect(instance[key]).toBe(setupOptions[key]);
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it('correctly passes instance to the methods even if no additional props have been passed', () => {
|
|
seInstance = new SourceEditorInstance();
|
|
seInstance.use({ definition: SEWithSetupExt });
|
|
|
|
const instance = seInstance.returnInstance();
|
|
|
|
expect(instance).toBe(seInstance);
|
|
});
|
|
|
|
it("correctly sets the context of the 'this' keyword for the extension's methods", () => {
|
|
seInstance = new SourceEditorInstance();
|
|
const extension = seInstance.use({ definition: SEWithSetupExt });
|
|
|
|
expect(seInstance.giveMeContext()).toEqual(extension.obj);
|
|
});
|
|
|
|
it('returns props from SE instance itself if no extension provides the prop', () => {
|
|
seInstance = new SourceEditorInstance({
|
|
use: fooFn,
|
|
});
|
|
const spy = jest.spyOn(seInstance.constructor.prototype, 'use').mockImplementation(() => {});
|
|
expect(spy).not.toHaveBeenCalled();
|
|
expect(fooFn).not.toHaveBeenCalled();
|
|
seInstance.use();
|
|
expect(spy).toHaveBeenCalled();
|
|
expect(fooFn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns props from Monaco instance when the prop does not exist on the SE instance', () => {
|
|
seInstance = new SourceEditorInstance({
|
|
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 SourceEditorInstance();
|
|
expect(seInstance[method]).toBeDefined();
|
|
});
|
|
|
|
describe('use', () => {
|
|
it('extends the SE instance with methods provided by an extension', () => {
|
|
seInstance = new SourceEditorInstance();
|
|
seInstance.use({ definition: DummyExt });
|
|
|
|
expect(fooFn).not.toHaveBeenCalled();
|
|
seInstance.fooFn();
|
|
expect(fooFn).toHaveBeenCalled();
|
|
});
|
|
|
|
it.each`
|
|
extensions | expectedProps
|
|
${{ definition: SEClassExtension }} | ${['shared', 'classExtMethod']}
|
|
${{ definition: SEFnExtension }} | ${['fnExtMethod']}
|
|
${{ definition: SEConstExt }} | ${['constExtMethod']}
|
|
${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
|
|
${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
|
|
`(
|
|
'Should register $expectedProps when extension is "$extensions"',
|
|
({ extensions, expectedProps }) => {
|
|
seInstance = new SourceEditorInstance();
|
|
expect(seInstance.extensionsAPI).toHaveLength(0);
|
|
|
|
seInstance.use(extensions);
|
|
|
|
expect(seInstance.extensionsAPI).toEqual(expectedProps);
|
|
},
|
|
);
|
|
|
|
it.each`
|
|
definition | preInstalledExtDefinition | expectedErrorProp
|
|
${conflictingExtensions.WithInstanceExt} | ${SEClassExtension} | ${'use'}
|
|
${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'}
|
|
${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined}
|
|
${conflictingExtensions.WithAnotherExt} | ${SEClassExtension} | ${'shared'}
|
|
${SEClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'}
|
|
`(
|
|
'logs the naming conflict error when registering $definition',
|
|
({ definition, preInstalledExtDefinition, expectedErrorProp }) => {
|
|
seInstance = new SourceEditorInstance();
|
|
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 SourceEditorInstance();
|
|
const useExtension = () => {
|
|
seInstance.use(extensions);
|
|
};
|
|
expect(useExtension).toThrowError(thrownError);
|
|
},
|
|
);
|
|
|
|
describe('global extensions registry', () => {
|
|
let extensionStore;
|
|
|
|
beforeEach(() => {
|
|
extensionStore = new Map();
|
|
seInstance = new SourceEditorInstance({}, extensionStore);
|
|
});
|
|
|
|
it('stores _instances_ of the used extensions in a global registry', () => {
|
|
const extension = seInstance.use({ definition: SEClassExtension });
|
|
|
|
expect(extensionStore.size).toBe(1);
|
|
expect(extensionStore.entries().next().value).toEqual(['SEClassExtension', extension]);
|
|
});
|
|
|
|
it('does not duplicate entries in the registry', () => {
|
|
jest.spyOn(extensionStore, 'set');
|
|
|
|
const extension1 = seInstance.use({ definition: SEClassExtension });
|
|
seInstance.use({ definition: SEClassExtension });
|
|
|
|
expect(extensionStore.set).toHaveBeenCalledTimes(1);
|
|
expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1);
|
|
});
|
|
|
|
it('correctly registers methods from the existing extension on an instance', () => {
|
|
const seInstance2 = new SourceEditorInstance({}, extensionStore);
|
|
seInstance.use({ definition: SEClassExtension });
|
|
const val1 = seInstance.classExtMethod();
|
|
|
|
seInstance2.use({ definition: SEClassExtension });
|
|
|
|
expect(seInstance2.classExtMethod).toBeDefined();
|
|
expect(seInstance2.classExtMethod()).toBe(val1); // from helpers.js we know classExtMethod()returns a string. Hence `toBe`
|
|
});
|
|
|
|
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: SEClassExtension,
|
|
setupOptions: currentSetupOptions,
|
|
});
|
|
const extension2 = seInstance.use({
|
|
definition: SEClassExtension,
|
|
setupOptions: newSetupOptions,
|
|
});
|
|
|
|
expect(extensionStore.size).toBe(1);
|
|
expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes);
|
|
if (expectedCallTimes > 1) {
|
|
expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension2);
|
|
} else {
|
|
expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', 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 SourceEditorInstance();
|
|
const unuse = () => {
|
|
seInstance.unuse(unuseExtension);
|
|
};
|
|
expect(unuse).toThrowError(thrownError);
|
|
},
|
|
);
|
|
|
|
it.each`
|
|
initExtensions | unuseExtensionIndex | remainingAPI
|
|
${{ definition: SEClassExtension }} | ${0} | ${[]}
|
|
${{ definition: SEFnExtension }} | ${0} | ${[]}
|
|
${{ definition: SEConstExt }} | ${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 SourceEditorInstance();
|
|
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 SourceEditorInstance();
|
|
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 SourceEditorInstance({}, extensionStore);
|
|
const extensions = seInstance.use(fullExtensionsArray);
|
|
const verifyExpectations = () => {
|
|
const entries = extensionStore.entries();
|
|
const mockExtensions = ['SEClassExtension', 'SEFnExtension', 'SEConstExt'];
|
|
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 SourceEditorInstance({
|
|
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 SourceEditorInstance();
|
|
seInstance.use({
|
|
definition: MyFullExtWithCallbacks,
|
|
setupOptions: defSetupOptions,
|
|
});
|
|
expect(onSetup).toHaveBeenCalledWith(seInstance, defSetupOptions);
|
|
expect(onUse).toHaveBeenCalledWith(seInstance);
|
|
});
|
|
|
|
it('passes correct arguments to callback fns when un-using an extension', () => {
|
|
seInstance = new SourceEditorInstance();
|
|
const extension = seInstance.use({
|
|
definition: MyFullExtWithCallbacks,
|
|
setupOptions: defSetupOptions,
|
|
});
|
|
seInstance.unuse(extension);
|
|
expect(onBeforeUnuse).toHaveBeenCalledWith(seInstance);
|
|
expect(onUnuse).toHaveBeenCalledWith(seInstance);
|
|
});
|
|
});
|
|
});
|
|
});
|