gitlab-org--gitlab-foss/spec/frontend/editor/source_editor_instance_spec.js

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).toThrow(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).toThrow(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('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.getLanguageId()).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);
});
});
});
});