summaryrefslogtreecommitdiff
path: root/spec/frontend/editor
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-11-18 13:16:36 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-11-18 13:16:36 +0000
commit311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch)
tree07e7870bca8aed6d61fdcc810731c50d2c40af47 /spec/frontend/editor
parent27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff)
downloadgitlab-ce-311b0269b4eb9839fa63f80c8d7a58f32b8138a0.tar.gz
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'spec/frontend/editor')
-rw-r--r--spec/frontend/editor/helpers.js53
-rw-r--r--spec/frontend/editor/source_editor_extension_base_spec.js68
-rw-r--r--spec/frontend/editor/source_editor_extension_spec.js65
-rw-r--r--spec/frontend/editor/source_editor_instance_spec.js387
-rw-r--r--spec/frontend/editor/source_editor_yaml_ext_spec.js449
5 files changed, 1010 insertions, 12 deletions
diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js
new file mode 100644
index 00000000000..6f7cdf6efb3
--- /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_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js
index 2c06ae03892..a0fb1178b3b 100644
--- a/spec/frontend/editor/source_editor_extension_base_spec.js
+++ b/spec/frontend/editor/source_editor_extension_base_spec.js
@@ -148,7 +148,10 @@ describe('The basis for an Source Editor extension', () => {
revealLineInCenter: revealSpy,
deltaDecorations: decorationsSpy,
};
- const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' };
+ const defaultDecorationOptions = {
+ isWholeLine: true,
+ className: 'active-line-text',
+ };
useFakeRequestAnimationFrame();
@@ -157,18 +160,22 @@ describe('The basis for an Source Editor extension', () => {
});
it.each`
- desc | hash | shouldReveal | expectedRange
- ${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]}
- ${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]}
- ${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]}
- ${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]}
- ${'does not highlight if there is no hash'} | ${''} | ${false} | ${null}
- ${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null}
- ${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null}
- ${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null}
- `('$desc', ({ hash, shouldReveal, expectedRange } = {}) => {
+ desc | hash | bounds | shouldReveal | expectedRange
+ ${'properly decorates a single line'} | ${'#L10'} | ${undefined} | ${true} | ${[10, 1, 10, 1]}
+ ${'properly decorates multiple lines'} | ${'#L7-42'} | ${undefined} | ${true} | ${[7, 1, 42, 1]}
+ ${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${undefined} | ${true} | ${[7, 1, 42, 1]}
+ ${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${undefined} | ${true} | ${[7, 1, 7, 1]}
+ ${'does not highlight if there is no hash'} | ${''} | ${undefined} | ${false} | ${null}
+ ${'does not highlight if the hash is undefined'} | ${undefined} | ${undefined} | ${false} | ${null}
+ ${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${undefined} | ${false} | ${null}
+ ${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${undefined} | ${false} | ${null}
+ ${'highlights lines if bounds are passed'} | ${undefined} | ${[17, 42]} | ${true} | ${[17, 1, 42, 1]}
+ ${'highlights one line if bounds has a single value'} | ${undefined} | ${[17]} | ${true} | ${[17, 1, 17, 1]}
+ ${'does not highlight if bounds is invalid'} | ${undefined} | ${[Number.NaN]} | ${false} | ${null}
+ ${'uses bounds if both hash and bounds exist'} | ${'#L7-42'} | ${[3, 5]} | ${true} | ${[3, 1, 5, 1]}
+ `('$desc', ({ hash, bounds, shouldReveal, expectedRange } = {}) => {
window.location.hash = hash;
- SourceEditorExtension.highlightLines(instance);
+ SourceEditorExtension.highlightLines(instance, bounds);
if (!shouldReveal) {
expect(revealSpy).not.toHaveBeenCalled();
expect(decorationsSpy).not.toHaveBeenCalled();
@@ -193,6 +200,43 @@ describe('The basis for an Source Editor extension', () => {
SourceEditorExtension.highlightLines(instance);
expect(instance.lineDecorations).toBe('foo');
});
+
+ it('replaces existing line highlights', () => {
+ const oldLineDecorations = [
+ {
+ range: new Range(1, 1, 20, 1),
+ options: { isWholeLine: true, className: 'active-line-text' },
+ },
+ ];
+ const newLineDecorations = [
+ {
+ range: new Range(7, 1, 10, 1),
+ options: { isWholeLine: true, className: 'active-line-text' },
+ },
+ ];
+ instance.lineDecorations = oldLineDecorations;
+ SourceEditorExtension.highlightLines(instance, [7, 10]);
+ expect(decorationsSpy).toHaveBeenCalledWith(oldLineDecorations, newLineDecorations);
+ });
+ });
+
+ describe('removeHighlights', () => {
+ const decorationsSpy = jest.fn();
+ const lineDecorations = [
+ {
+ range: new Range(1, 1, 20, 1),
+ options: { isWholeLine: true, className: 'active-line-text' },
+ },
+ ];
+ const instance = {
+ deltaDecorations: decorationsSpy,
+ lineDecorations,
+ };
+
+ it('removes all existing decorations', () => {
+ SourceEditorExtension.removeHighlights(instance);
+ expect(decorationsSpy).toHaveBeenCalledWith(lineDecorations, []);
+ });
});
describe('setupLineLinking', () => {
diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js
new file mode 100644
index 00000000000..6f2eb07a043
--- /dev/null
+++ b/spec/frontend/editor/source_editor_extension_spec.js
@@ -0,0 +1,65 @@
+import EditorExtension from '~/editor/source_editor_extension';
+import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants';
+import * as helpers from './helpers';
+
+describe('Editor Extension', () => {
+ const dummyObj = { foo: 'bar' };
+
+ it.each`
+ definition | setupOptions
+ ${undefined} | ${undefined}
+ ${undefined} | ${{}}
+ ${undefined} | ${dummyObj}
+ ${{}} | ${dummyObj}
+ ${dummyObj} | ${dummyObj}
+ `(
+ 'throws when definition = $definition and setupOptions = $setupOptions',
+ ({ definition, setupOptions }) => {
+ const constructExtension = () => new EditorExtension({ definition, setupOptions });
+ expect(constructExtension).toThrowError(EDITOR_EXTENSION_DEFINITION_ERROR);
+ },
+ );
+
+ it.each`
+ 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 }) => {
+ const extension = new EditorExtension({ definition, setupOptions });
+ // eslint-disable-next-line new-cap
+ const constructedDefinition = new definition();
+
+ expect(extension).toEqual(
+ expect.objectContaining({
+ name: expectedName,
+ setupOptions,
+ }),
+ );
+ expect(extension.obj.constructor.prototype).toBe(constructedDefinition.constructor.prototype);
+ },
+ );
+
+ describe('api', () => {
+ it.each`
+ 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(
+ expectedKeys.map((key) => [key, expect.any(Function)]),
+ );
+ expect(extension.api).toEqual(expect.objectContaining(expectedApi));
+ });
+ });
+});
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 00000000000..87b20a4ba73
--- /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);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/editor/source_editor_yaml_ext_spec.js b/spec/frontend/editor/source_editor_yaml_ext_spec.js
new file mode 100644
index 00000000000..97d2b0b21d0
--- /dev/null
+++ b/spec/frontend/editor/source_editor_yaml_ext_spec.js
@@ -0,0 +1,449 @@
+import { Document } from 'yaml';
+import SourceEditor from '~/editor/source_editor';
+import { YamlEditorExtension } from '~/editor/extensions/source_editor_yaml_ext';
+import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
+
+const getEditorInstance = (editorInstanceOptions = {}) => {
+ setFixtures('<div id="editor"></div>');
+ return new SourceEditor().createInstance({
+ el: document.getElementById('editor'),
+ blobPath: '.gitlab-ci.yml',
+ language: 'yaml',
+ ...editorInstanceOptions,
+ });
+};
+
+const getEditorInstanceWithExtension = (extensionOptions = {}, editorInstanceOptions = {}) => {
+ setFixtures('<div id="editor"></div>');
+ const instance = getEditorInstance(editorInstanceOptions);
+ instance.use(new YamlEditorExtension({ instance, ...extensionOptions }));
+
+ // Remove the below once
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/325992 is resolved
+ if (editorInstanceOptions.value && !extensionOptions.model) {
+ instance.setValue(editorInstanceOptions.value);
+ }
+
+ return instance;
+};
+
+describe('YamlCreatorExtension', () => {
+ describe('constructor', () => {
+ it('saves constructor options', () => {
+ const instance = getEditorInstanceWithExtension({
+ highlightPath: 'foo',
+ enableComments: true,
+ });
+ expect(instance).toEqual(
+ expect.objectContaining({
+ options: expect.objectContaining({
+ highlightPath: 'foo',
+ enableComments: true,
+ }),
+ }),
+ );
+ });
+
+ it('dumps values loaded with the model constructor options', () => {
+ const model = { foo: 'bar' };
+ const expected = 'foo: bar\n';
+ const instance = getEditorInstanceWithExtension({ model });
+ expect(instance.getDoc().get('foo')).toBeDefined();
+ expect(instance.getValue()).toEqual(expected);
+ });
+
+ it('registers the onUpdate() function', () => {
+ const instance = getEditorInstance();
+ const onDidChangeModelContent = jest.spyOn(instance, 'onDidChangeModelContent');
+ instance.use(new YamlEditorExtension({ instance }));
+ expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it("If not provided with a load constructor option, it will parse the editor's value", () => {
+ const editorValue = 'foo: bar';
+ const instance = getEditorInstanceWithExtension({}, { value: editorValue });
+ expect(instance.getDoc().get('foo')).toBeDefined();
+ });
+
+ it("Prefers values loaded with the load constructor option over the editor's existing value", () => {
+ const editorValue = 'oldValue: this should be overriden';
+ const model = { thisShould: 'be the actual value' };
+ const expected = 'thisShould: be the actual value\n';
+ const instance = getEditorInstanceWithExtension({ model }, { value: editorValue });
+ expect(instance.getDoc().get('oldValue')).toBeUndefined();
+ expect(instance.getValue()).toEqual(expected);
+ });
+ });
+
+ describe('initFromModel', () => {
+ const model = { foo: 'bar', 1: 2, abc: ['def'] };
+ const doc = new Document(model);
+
+ it('should call transformComments if enableComments is true', () => {
+ const instance = getEditorInstanceWithExtension({ enableComments: true });
+ const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
+ YamlEditorExtension.initFromModel(instance, model);
+ expect(transformComments).toHaveBeenCalled();
+ });
+
+ it('should not call transformComments if enableComments is false', () => {
+ const instance = getEditorInstanceWithExtension({ enableComments: false });
+ const transformComments = jest.spyOn(YamlEditorExtension, 'transformComments');
+ YamlEditorExtension.initFromModel(instance, model);
+ expect(transformComments).not.toHaveBeenCalled();
+ });
+
+ it('should call setValue with the stringified model', () => {
+ const instance = getEditorInstanceWithExtension();
+ const setValue = jest.spyOn(instance, 'setValue');
+ YamlEditorExtension.initFromModel(instance, model);
+ expect(setValue).toHaveBeenCalledWith(doc.toString());
+ });
+ });
+
+ describe('wrapCommentString', () => {
+ const longString =
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.';
+
+ it('should add spaces before each line', () => {
+ const result = YamlEditorExtension.wrapCommentString(longString);
+ const lines = result.split('\n');
+ expect(lines.every((ln) => ln.startsWith(' '))).toBe(true);
+ });
+
+ it('should break long comments into lines of max. 79 chars', () => {
+ // 79 = 80 char width minus 1 char for the '#' at the start of each line
+ const result = YamlEditorExtension.wrapCommentString(longString);
+ const lines = result.split('\n');
+ expect(lines.every((ln) => ln.length <= 79)).toBe(true);
+ });
+
+ it('should decrease the line width if passed a level by 2 chars per level', () => {
+ for (let i = 0; i <= 5; i += 1) {
+ const result = YamlEditorExtension.wrapCommentString(longString, i);
+ const lines = result.split('\n');
+ const decreaseLineWidthBy = i * 2;
+ const maxLineWith = 79 - decreaseLineWidthBy;
+ const isValidLine = (ln) => {
+ if (ln.length <= maxLineWith) return true;
+ // The line may exceed the max line width in case the word is the
+ // only one in the line and thus cannot be broken further
+ return ln.split(' ').length <= 1;
+ };
+ expect(lines.every(isValidLine)).toBe(true);
+ }
+ });
+
+ it('return null if passed an invalid string value', () => {
+ expect(YamlEditorExtension.wrapCommentString(null)).toBe(null);
+ expect(YamlEditorExtension.wrapCommentString()).toBe(null);
+ });
+
+ it('throw an error if passed an invalid level value', () => {
+ expect(() => YamlEditorExtension.wrapCommentString('abc', -5)).toThrow(
+ 'Invalid value "-5" for variable `level`',
+ );
+ expect(() => YamlEditorExtension.wrapCommentString('abc', 'invalid')).toThrow(
+ 'Invalid value "invalid" for variable `level`',
+ );
+ });
+ });
+
+ describe('transformComments', () => {
+ const getInstanceWithModel = (model) => {
+ return getEditorInstanceWithExtension({
+ model,
+ enableComments: true,
+ });
+ };
+
+ it('converts comments inside an array', () => {
+ const model = ['# test comment', 'def', '# foo', 999];
+ const expected = `# test comment\n- def\n# foo\n- 999\n`;
+ const instance = getInstanceWithModel(model);
+ expect(instance.getValue()).toEqual(expected);
+ });
+
+ it('converts generic comments inside an object and places them at the top', () => {
+ const model = { foo: 'bar', 1: 2, '#': 'test comment' };
+ const expected = `# test comment\n"1": 2\nfoo: bar\n`;
+ const instance = getInstanceWithModel(model);
+ expect(instance.getValue()).toEqual(expected);
+ });
+
+ it('adds specific comments before the mentioned entry of an object', () => {
+ const model = { foo: 'bar', 1: 2, '#|foo': 'foo comment' };
+ const expected = `"1": 2\n# foo comment\nfoo: bar\n`;
+ const instance = getInstanceWithModel(model);
+ expect(instance.getValue()).toEqual(expected);
+ });
+
+ it('limits long comments to 80 char width, including indentation', () => {
+ const model = {
+ '#|foo':
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.',
+ foo: {
+ nested1: {
+ nested2: {
+ nested3: {
+ '#|bar':
+ 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.',
+ bar: 'baz',
+ },
+ },
+ },
+ },
+ };
+ const expected = `# Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
+# eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
+# voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
+foo:
+ nested1:
+ nested2:
+ nested3:
+ # Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam
+ # nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat,
+ # sed diam voluptua. At vero eos et accusam et justo duo dolores et ea
+ # rebum.
+ bar: baz
+`;
+ const instance = getInstanceWithModel(model);
+ expect(instance.getValue()).toEqual(expected);
+ });
+ });
+
+ describe('getDoc', () => {
+ it('returns a yaml `Document` Type', () => {
+ const instance = getEditorInstanceWithExtension();
+ expect(instance.getDoc()).toBeInstanceOf(Document);
+ });
+ });
+
+ describe('setDoc', () => {
+ const model = { foo: 'bar', 1: 2, abc: ['def'] };
+ const doc = new Document(model);
+
+ it('should call transformComments if enableComments is true', () => {
+ const spy = jest.spyOn(YamlEditorExtension, 'transformComments');
+ const instance = getEditorInstanceWithExtension({ enableComments: true });
+ instance.setDoc(doc);
+ expect(spy).toHaveBeenCalledWith(doc);
+ });
+
+ it('should not call transformComments if enableComments is false', () => {
+ const spy = jest.spyOn(YamlEditorExtension, 'transformComments');
+ const instance = getEditorInstanceWithExtension({ enableComments: false });
+ instance.setDoc(doc);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it("should call setValue with the stringified doc if the editor's value is empty", () => {
+ const instance = getEditorInstanceWithExtension();
+ const setValue = jest.spyOn(instance, 'setValue');
+ const updateValue = jest.spyOn(instance, 'updateValue');
+ instance.setDoc(doc);
+ expect(setValue).toHaveBeenCalledWith(doc.toString());
+ expect(updateValue).not.toHaveBeenCalled();
+ });
+
+ it("should call updateValue with the stringified doc if the editor's value is not empty", () => {
+ const instance = getEditorInstanceWithExtension({}, { value: 'asjkdhkasjdh' });
+ const setValue = jest.spyOn(instance, 'setValue');
+ const updateValue = jest.spyOn(instance, 'updateValue');
+ instance.setDoc(doc);
+ expect(setValue).not.toHaveBeenCalled();
+ expect(updateValue).toHaveBeenCalledWith(doc.toString());
+ });
+
+ it('should trigger the onUpdate method', () => {
+ const instance = getEditorInstanceWithExtension();
+ const onUpdate = jest.spyOn(instance, 'onUpdate');
+ instance.setDoc(doc);
+ expect(onUpdate).toHaveBeenCalled();
+ });
+ });
+
+ describe('getDataModel', () => {
+ it('returns the model as JS', () => {
+ const value = 'abc: def\nfoo:\n - bar\n - baz\n';
+ const expected = { abc: 'def', foo: ['bar', 'baz'] };
+ const instance = getEditorInstanceWithExtension({}, { value });
+ expect(instance.getDataModel()).toEqual(expected);
+ });
+ });
+
+ describe('setDataModel', () => {
+ it('sets the value to a YAML-representation of the Doc', () => {
+ const model = {
+ abc: ['def'],
+ '#|foo': 'foo comment',
+ foo: {
+ '#|abc': 'abc comment',
+ abc: [{ def: 'ghl', lorem: 'ipsum' }, '# array comment', null],
+ bar: 'baz',
+ },
+ };
+ const expected =
+ 'abc:\n' +
+ ' - def\n' +
+ '# foo comment\n' +
+ 'foo:\n' +
+ ' # abc comment\n' +
+ ' abc:\n' +
+ ' - def: ghl\n' +
+ ' lorem: ipsum\n' +
+ ' # array comment\n' +
+ ' - null\n' +
+ ' bar: baz\n';
+
+ const instance = getEditorInstanceWithExtension({ enableComments: true });
+ const setValue = jest.spyOn(instance, 'setValue');
+
+ instance.setDataModel(model);
+
+ expect(setValue).toHaveBeenCalledWith(expected);
+ });
+
+ it('causes the editor value to be updated', () => {
+ const initialModel = { foo: 'this should be overriden' };
+ const initialValue = 'foo: this should be overriden\n';
+ const newValue = { thisShould: 'be the actual value' };
+ const expected = 'thisShould: be the actual value\n';
+ const instance = getEditorInstanceWithExtension({ model: initialModel });
+ expect(instance.getValue()).toEqual(initialValue);
+ instance.setDataModel(newValue);
+ expect(instance.getValue()).toEqual(expected);
+ });
+ });
+
+ describe('onUpdate', () => {
+ it('calls highlight', () => {
+ const highlightPath = 'foo';
+ const instance = getEditorInstanceWithExtension({ highlightPath });
+ instance.highlight = jest.fn();
+ instance.onUpdate();
+ expect(instance.highlight).toHaveBeenCalledWith(highlightPath);
+ });
+ });
+
+ describe('updateValue', () => {
+ it("causes the editor's value to be updated", () => {
+ const oldValue = 'foobar';
+ const newValue = 'bazboo';
+ const instance = getEditorInstanceWithExtension({}, { value: oldValue });
+ instance.updateValue(newValue);
+ expect(instance.getValue()).toEqual(newValue);
+ });
+ });
+
+ describe('highlight', () => {
+ const highlightPathOnSetup = 'abc';
+ const value = `foo:
+ bar:
+ - baz
+ - boo
+ abc: def
+`;
+ let instance;
+ let highlightLinesSpy;
+ let removeHighlightsSpy;
+
+ beforeEach(() => {
+ instance = getEditorInstanceWithExtension({ highlightPath: highlightPathOnSetup }, { value });
+ highlightLinesSpy = jest.spyOn(SourceEditorExtension, 'highlightLines');
+ removeHighlightsSpy = jest.spyOn(SourceEditorExtension, 'removeHighlights');
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('saves the highlighted path in highlightPath', () => {
+ const path = 'foo.bar';
+ instance.highlight(path);
+ expect(instance.options.highlightPath).toEqual(path);
+ });
+
+ it('calls highlightLines with a number of lines', () => {
+ const path = 'foo.bar';
+ instance.highlight(path);
+ expect(highlightLinesSpy).toHaveBeenCalledWith(instance, [2, 4]);
+ });
+
+ it('calls removeHighlights if path is null', () => {
+ instance.highlight(null);
+ expect(removeHighlightsSpy).toHaveBeenCalledWith(instance);
+ expect(highlightLinesSpy).not.toHaveBeenCalled();
+ expect(instance.options.highlightPath).toBeNull();
+ });
+
+ it('throws an error if path is invalid and does not change the highlighted path', () => {
+ expect(() => instance.highlight('invalidPath[0]')).toThrow(
+ 'The node invalidPath[0] could not be found inside the document.',
+ );
+ expect(instance.options.highlightPath).toEqual(highlightPathOnSetup);
+ expect(highlightLinesSpy).not.toHaveBeenCalled();
+ expect(removeHighlightsSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('locate', () => {
+ const options = {
+ enableComments: true,
+ model: {
+ abc: ['def'],
+ '#|foo': 'foo comment',
+ foo: {
+ '#|abc': 'abc comment',
+ abc: [{ def: 'ghl', lorem: 'ipsum' }, '# array comment', null],
+ bar: 'baz',
+ },
+ },
+ };
+
+ const value =
+ /* 1 */ 'abc:\n' +
+ /* 2 */ ' - def\n' +
+ /* 3 */ '# foo comment\n' +
+ /* 4 */ 'foo:\n' +
+ /* 5 */ ' # abc comment\n' +
+ /* 6 */ ' abc:\n' +
+ /* 7 */ ' - def: ghl\n' +
+ /* 8 */ ' lorem: ipsum\n' +
+ /* 9 */ ' # array comment\n' +
+ /* 10 */ ' - null\n' +
+ /* 11 */ ' bar: baz\n';
+
+ it('asserts that the test setup is correct', () => {
+ const instance = getEditorInstanceWithExtension(options);
+ expect(instance.getValue()).toEqual(value);
+ });
+
+ it('returns the expected line numbers for a path to an object inside the yaml', () => {
+ const path = 'foo.abc';
+ const expected = [6, 10];
+ const instance = getEditorInstanceWithExtension(options);
+ expect(instance.locate(path)).toEqual(expected);
+ });
+
+ it('throws an error if a path cannot be found inside the yaml', () => {
+ const path = 'baz[8]';
+ const instance = getEditorInstanceWithExtension(options);
+ expect(() => instance.locate(path)).toThrow();
+ });
+
+ it('returns the expected line numbers for a path to an array entry inside the yaml', () => {
+ const path = 'foo.abc[0]';
+ const expected = [7, 8];
+ const instance = getEditorInstanceWithExtension(options);
+ expect(instance.locate(path)).toEqual(expected);
+ });
+
+ it('returns the expected line numbers for a path that includes a comment inside the yaml', () => {
+ const path = 'foo';
+ const expected = [4, 11];
+ const instance = getEditorInstanceWithExtension(options);
+ expect(instance.locate(path)).toEqual(expected);
+ });
+ });
+});