summaryrefslogtreecommitdiff
path: root/spec/frontend/vue_shared/components/rich_content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/vue_shared/components/rich_content_editor')
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js62
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js76
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js43
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js50
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js88
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js58
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js55
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js65
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js55
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js30
16 files changed, 735 insertions, 59 deletions
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
index faa32131fab..78f27c9948b 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
@@ -2,18 +2,35 @@ import {
generateToolbarItem,
addCustomEventListener,
removeCustomEventListener,
+ registerHTMLToMarkdownRenderer,
addImage,
getMarkdown,
-} from '~/vue_shared/components/rich_content_editor/editor_service';
+} from '~/vue_shared/components/rich_content_editor/services/editor_service';
+import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
+
+jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer');
describe('Editor Service', () => {
- const mockInstance = {
- eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
- editor: { exec: jest.fn() },
- invoke: jest.fn(),
- };
- const event = 'someCustomEvent';
- const handler = jest.fn();
+ let mockInstance;
+ let event;
+ let handler;
+
+ beforeEach(() => {
+ mockInstance = {
+ eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
+ editor: { exec: jest.fn() },
+ invoke: jest.fn(),
+ toMarkOptions: {
+ renderer: {
+ constructor: {
+ factory: jest.fn(),
+ },
+ },
+ },
+ };
+ event = 'someCustomEvent';
+ handler = jest.fn();
+ });
describe('generateToolbarItem', () => {
const config = {
@@ -74,4 +91,33 @@ describe('Editor Service', () => {
expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown');
});
});
+
+ describe('registerHTMLToMarkdownRenderer', () => {
+ let baseRenderer;
+ const htmlToMarkdownRenderer = {};
+ const extendedRenderer = {};
+
+ beforeEach(() => {
+ baseRenderer = mockInstance.toMarkOptions.renderer;
+ buildHTMLToMarkdownRenderer.mockReturnValueOnce(htmlToMarkdownRenderer);
+ baseRenderer.constructor.factory.mockReturnValueOnce(extendedRenderer);
+
+ registerHTMLToMarkdownRenderer(mockInstance);
+ });
+
+ it('builds a new instance of the HTML to Markdown renderer', () => {
+ expect(buildHTMLToMarkdownRenderer).toHaveBeenCalledWith(baseRenderer);
+ });
+
+ it('extends base renderer with the HTML to Markdown renderer', () => {
+ expect(baseRenderer.constructor.factory).toHaveBeenCalledWith(
+ baseRenderer,
+ htmlToMarkdownRenderer,
+ );
+ });
+
+ it('replaces the default renderer with extended renderer', () => {
+ expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js
new file mode 100644
index 00000000000..0c2ac53aa52
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js
@@ -0,0 +1,76 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal, GlTabs } from '@gitlab/ui';
+import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
+import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue';
+import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constants';
+
+describe('Add Image Modal', () => {
+ let wrapper;
+ const propsData = { imageRoot: 'path/to/root/' };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findTabs = () => wrapper.find(GlTabs);
+ const findUploadImageTab = () => wrapper.find(UploadImageTab);
+ const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
+ const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
+
+ beforeEach(() => {
+ wrapper = shallowMount(AddImageModal, {
+ provide: { glFeatures: { sseImageUploads: true } },
+ propsData,
+ });
+ });
+
+ describe('when content is loaded', () => {
+ it('renders a modal component', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('renders a Tabs component', () => {
+ expect(findTabs().exists()).toBe(true);
+ });
+
+ it('renders an upload image tab', () => {
+ expect(findUploadImageTab().exists()).toBe(true);
+ });
+
+ it('renders an input to add an image URL', () => {
+ expect(findUrlInput().exists()).toBe(true);
+ });
+
+ it('renders an input to add an image description', () => {
+ expect(findDescriptionInput().exists()).toBe(true);
+ });
+ });
+
+ describe('add image', () => {
+ describe('Upload', () => {
+ it('validates the file', () => {
+ const preventDefault = jest.fn();
+ const description = 'some description';
+ const file = { name: 'some_file.png' };
+
+ wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() };
+ wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB });
+
+ findModal().vm.$emit('ok', { preventDefault });
+
+ expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled();
+ });
+ });
+
+ describe('URL', () => {
+ it('emits an addImage event when a valid URL is specified', () => {
+ const preventDefault = jest.fn();
+ const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' };
+ wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB });
+
+ findModal().vm.$emit('ok', { preventDefault });
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(wrapper.emitted('addImage')).toEqual([
+ [{ imageUrl: mockImage.imageUrl, altText: mockImage.description }],
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js
new file mode 100644
index 00000000000..ded490b2568
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js
@@ -0,0 +1,41 @@
+import { shallowMount } from '@vue/test-utils';
+import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue';
+
+describe('Upload Image Tab', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(UploadImageTab);
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ const triggerInputEvent = size => {
+ const file = { size, name: 'file-name.png' };
+ const mockEvent = new Event('input');
+
+ Object.defineProperty(mockEvent, 'target', { value: { files: [file] } });
+
+ wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent);
+
+ return file;
+ };
+
+ describe('onInput', () => {
+ it.each`
+ size | fileError
+ ${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'}
+ ${200} | ${null}
+ `('validates the file correctly', ({ size, fileError }) => {
+ triggerInputEvent(size);
+
+ expect(wrapper.vm.fileError).toBe(fileError);
+ });
+ });
+
+ it('emits input event when file is valid', () => {
+ const file = triggerInputEvent(200);
+
+ expect(wrapper.emitted('input')).toEqual([[file]]);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js
deleted file mode 100644
index 4889bc8538d..00000000000
--- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image_modal_spec.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlModal } from '@gitlab/ui';
-import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue';
-
-describe('Add Image Modal', () => {
- let wrapper;
-
- const findModal = () => wrapper.find(GlModal);
- const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
- const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
-
- beforeEach(() => {
- wrapper = shallowMount(AddImageModal);
- });
-
- describe('when content is loaded', () => {
- it('renders a modal component', () => {
- expect(findModal().exists()).toBe(true);
- });
-
- it('renders an input to add an image URL', () => {
- expect(findUrlInput().exists()).toBe(true);
- });
-
- it('renders an input to add an image description', () => {
- expect(findDescriptionInput().exists()).toBe(true);
- });
- });
-
- describe('add image', () => {
- it('emits an addImage event when a valid URL is specified', () => {
- const preventDefault = jest.fn();
- const mockImage = { imageUrl: '/some/valid/url.png', altText: 'some description' };
- wrapper.setData({ ...mockImage });
-
- findModal().vm.$emit('ok', { preventDefault });
- expect(preventDefault).not.toHaveBeenCalled();
- expect(wrapper.emitted('addImage')).toEqual([[mockImage]]);
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
index 0db10389df4..b6ff6aa767c 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
@@ -1,6 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
-import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image_modal.vue';
+import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import {
EDITOR_OPTIONS,
EDITOR_TYPES,
@@ -13,25 +13,28 @@ import {
addCustomEventListener,
removeCustomEventListener,
addImage,
-} from '~/vue_shared/components/rich_content_editor/editor_service';
+ registerHTMLToMarkdownRenderer,
+} from '~/vue_shared/components/rich_content_editor/services/editor_service';
-jest.mock('~/vue_shared/components/rich_content_editor/editor_service', () => ({
- ...jest.requireActual('~/vue_shared/components/rich_content_editor/editor_service'),
+jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({
+ ...jest.requireActual('~/vue_shared/components/rich_content_editor/services/editor_service'),
addCustomEventListener: jest.fn(),
removeCustomEventListener: jest.fn(),
addImage: jest.fn(),
+ registerHTMLToMarkdownRenderer: jest.fn(),
}));
describe('Rich Content Editor', () => {
let wrapper;
- const value = '## Some Markdown';
+ const content = '## Some Markdown';
+ const imageRoot = 'path/to/root/';
const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
beforeEach(() => {
wrapper = shallowMount(RichContentEditor, {
- propsData: { value },
+ propsData: { content, imageRoot },
});
});
@@ -41,7 +44,7 @@ describe('Rich Content Editor', () => {
});
it('renders the correct content', () => {
- expect(findEditor().props().initialValue).toBe(value);
+ expect(findEditor().props().initialValue).toBe(content);
});
it('provides the correct editor options', () => {
@@ -73,17 +76,37 @@ describe('Rich Content Editor', () => {
});
});
+ describe('when content is reset', () => {
+ it('should reset the content via setMarkdown', () => {
+ const newContent = 'Just the body content excluding the front matter for example';
+ const mockInstance = { invoke: jest.fn() };
+ wrapper.vm.$refs.editor = mockInstance;
+
+ wrapper.vm.resetInitialValue(newContent);
+
+ expect(mockInstance.invoke).toHaveBeenCalledWith('setMarkdown', newContent);
+ });
+ });
+
describe('when editor is loaded', () => {
- it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
- const mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } };
+ let mockEditorApi;
+
+ beforeEach(() => {
+ mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } };
findEditor().vm.$emit('load', mockEditorApi);
+ });
+ it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
expect(addCustomEventListener).toHaveBeenCalledWith(
mockEditorApi,
CUSTOM_EVENTS.openAddImageModal,
wrapper.vm.onOpenAddImageModal,
);
});
+
+ it('registers HTML to markdown renderer', () => {
+ expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(mockEditorApi);
+ });
});
describe('when editor is destroyed', () => {
@@ -107,7 +130,7 @@ describe('Rich Content Editor', () => {
});
it('calls the onAddImage method when the addImage event is emitted', () => {
- const mockImage = { imageUrl: 'some/url.png', description: 'some description' };
+ const mockImage = { imageUrl: 'some/url.png', altText: 'some description' };
const mockInstance = { exec: jest.fn() };
wrapper.vm.$refs.editor = mockInstance;
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js
new file mode 100644
index 00000000000..cafe53e6bb2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js
@@ -0,0 +1,29 @@
+import buildCustomHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer';
+
+describe('Build Custom Renderer Service', () => {
+ describe('buildCustomHTMLRenderer', () => {
+ it('should return an object with the default renderer functions when lacking arguments', () => {
+ expect(buildCustomHTMLRenderer()).toEqual(
+ expect.objectContaining({
+ list: expect.any(Function),
+ text: expect.any(Function),
+ }),
+ );
+ });
+
+ it('should return an object with both custom and default renderer functions when passed customRenderers', () => {
+ const mockHtmlCustomRenderer = jest.fn();
+ const customRenderers = {
+ html: [mockHtmlCustomRenderer],
+ };
+
+ expect(buildCustomHTMLRenderer(customRenderers)).toEqual(
+ expect.objectContaining({
+ html: expect.any(Function),
+ list: expect.any(Function),
+ text: expect.any(Function),
+ }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
new file mode 100644
index 00000000000..0e8610a22f5
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
@@ -0,0 +1,50 @@
+import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
+
+describe('HTMLToMarkdownRenderer', () => {
+ let baseRenderer;
+ let htmlToMarkdownRenderer;
+ const NODE = { nodeValue: 'mock_node' };
+
+ beforeEach(() => {
+ baseRenderer = {
+ trim: jest.fn(input => `trimmed ${input}`),
+ getSpaceCollapsedText: jest.fn(input => `space collapsed ${input}`),
+ getSpaceControlled: jest.fn(input => `space controlled ${input}`),
+ convert: jest.fn(),
+ };
+ });
+
+ describe('TEXT_NODE visitor', () => {
+ it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+
+ expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe(
+ `space controlled trimmed space collapsed ${NODE.nodeValue}`,
+ );
+ });
+ });
+
+ describe('LI OL, LI UL visitor', () => {
+ const oneLevelNestedList = '\n * List item 1\n * List item 2';
+ const twoLevelNestedList = '\n * List item 1\n * List item 2';
+ const spaceInContentList = '\n * List item 1\n * List item 2';
+
+ it.each`
+ list | indentSpaces | result
+ ${oneLevelNestedList} | ${2} | ${'\n * List item 1\n * List item 2'}
+ ${oneLevelNestedList} | ${3} | ${'\n * List item 1\n * List item 2'}
+ ${oneLevelNestedList} | ${6} | ${'\n * List item 1\n * List item 2'}
+ ${twoLevelNestedList} | ${4} | ${'\n * List item 1\n * List item 2'}
+ ${spaceInContentList} | ${1} | ${'\n * List item 1\n * List item 2'}
+ `('changes the list indentation to $indentSpaces spaces', ({ list, indentSpaces, result }) => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, {
+ subListIndentSpaces: indentSpaces,
+ });
+
+ baseRenderer.convert.mockReturnValueOnce(list);
+
+ expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
new file mode 100644
index 00000000000..18dff0a39bb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js
@@ -0,0 +1,88 @@
+import {
+ buildTextToken,
+ buildUneditableOpenTokens,
+ buildUneditableCloseToken,
+ buildUneditableCloseTokens,
+ buildUneditableTokens,
+ buildUneditableInlineTokens,
+ buildUneditableHtmlAsTextTokens,
+} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import {
+ originInlineToken,
+ originToken,
+ uneditableOpenTokens,
+ uneditableCloseToken,
+ uneditableCloseTokens,
+ uneditableBlockTokens,
+ uneditableInlineTokens,
+ uneditableTokens,
+} from './mock_data';
+
+describe('Build Uneditable Token renderer helper', () => {
+ describe('buildTextToken', () => {
+ it('returns an object literal representing a text token', () => {
+ const text = originToken.content;
+ expect(buildTextToken(text)).toStrictEqual(originToken);
+ });
+ });
+
+ describe('buildUneditableOpenTokens', () => {
+ it('returns a 2-item array of tokens with the originToken appended to an open token', () => {
+ const result = buildUneditableOpenTokens(originToken);
+
+ expect(result).toHaveLength(2);
+ expect(result).toStrictEqual(uneditableOpenTokens);
+ });
+ });
+
+ describe('buildUneditableCloseToken', () => {
+ it('returns an object literal representing the uneditable close token', () => {
+ expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken);
+ });
+ });
+
+ describe('buildUneditableCloseTokens', () => {
+ it('returns a 2-item array of tokens with the originToken prepended to a close token', () => {
+ const result = buildUneditableCloseTokens(originToken);
+
+ expect(result).toHaveLength(2);
+ expect(result).toStrictEqual(uneditableCloseTokens);
+ });
+ });
+
+ describe('buildUneditableTokens', () => {
+ it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => {
+ const result = buildUneditableTokens(originToken);
+
+ expect(result).toHaveLength(3);
+ expect(result).toStrictEqual(uneditableTokens);
+ });
+ });
+
+ describe('buildUneditableInlineTokens', () => {
+ it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => {
+ const result = buildUneditableInlineTokens(originInlineToken);
+
+ expect(result).toHaveLength(3);
+ expect(result).toStrictEqual(uneditableInlineTokens);
+ });
+ });
+
+ describe('buildUneditableHtmlAsTextTokens', () => {
+ it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => {
+ const htmlBlockNode = {
+ type: 'htmlBlock',
+ literal: '<div data-tomark-pass ><h1>Some header</h1><p>Some paragraph</p></div>',
+ };
+ const result = buildUneditableHtmlAsTextTokens(htmlBlockNode);
+ const { type, content } = result[1];
+
+ expect(type).toBe('text');
+ expect(content).not.toMatch(/ data-tomark-pass /);
+
+ expect(result).toHaveLength(3);
+ expect(result).toStrictEqual(uneditableBlockTokens);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
new file mode 100644
index 00000000000..660c21281fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js
@@ -0,0 +1,58 @@
+// Node spec helpers
+
+export const buildMockTextNode = literal => {
+ return {
+ firstChild: null,
+ literal,
+ type: 'text',
+ };
+};
+
+export const normalTextNode = buildMockTextNode('This is just normal text.');
+
+// Token spec helpers
+
+const buildMockUneditableOpenToken = type => {
+ return {
+ type: 'openTag',
+ tagName: type,
+ attributes: { contenteditable: false },
+ classNames: [
+ 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
+ ],
+ };
+};
+
+const buildMockUneditableCloseToken = type => {
+ return { type: 'closeTag', tagName: type };
+};
+
+export const originToken = {
+ type: 'text',
+ tagName: null,
+ content: '{:.no_toc .hidden-md .hidden-lg}',
+};
+export const uneditableCloseToken = buildMockUneditableCloseToken('div');
+export const uneditableOpenTokens = [buildMockUneditableOpenToken('div'), originToken];
+export const uneditableCloseTokens = [originToken, uneditableCloseToken];
+export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken];
+
+export const originInlineToken = {
+ type: 'text',
+ content: '<i>Inline</i> content',
+};
+export const uneditableInlineTokens = [
+ buildMockUneditableOpenToken('a'),
+ originInlineToken,
+ buildMockUneditableCloseToken('a'),
+];
+
+export const uneditableBlockTokens = [
+ buildMockUneditableOpenToken('div'),
+ {
+ type: 'text',
+ tagName: null,
+ content: '<div><h1>Some header</h1><p>Some paragraph</p></div>',
+ },
+ buildMockUneditableCloseToken('div'),
+];
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js
new file mode 100644
index 00000000000..b723ee8c8a0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js
@@ -0,0 +1,30 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text';
+import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { buildMockTextNode, normalTextNode } from './mock_data';
+
+const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>');
+
+describe('Render Embedded Ruby Text renderer', () => {
+ describe('canRender', () => {
+ it('should return true when the argument `literal` has embedded ruby syntax', () => {
+ expect(renderer.canRender(embeddedRubyTextNode)).toBe(true);
+ });
+
+ it('should return false when the argument `literal` lacks embedded ruby syntax', () => {
+ expect(renderer.canRender(normalTextNode)).toBe(false);
+ });
+ });
+
+ describe('render', () => {
+ const origin = jest.fn();
+
+ it('should return uneditable tokens', () => {
+ const context = { origin };
+
+ expect(renderer.render(embeddedRubyTextNode, context)).toStrictEqual(
+ buildUneditableTokens(origin()),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js
new file mode 100644
index 00000000000..d6bb01259bb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js
@@ -0,0 +1,33 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline';
+import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { normalTextNode } from './mock_data';
+
+const fontAwesomeInlineHtmlNode = {
+ firstChild: null,
+ literal: '<i class="far fa-paper-plane" id="biz-tech-icons">',
+ type: 'html',
+};
+
+describe('Render Font Awesome Inline HTML renderer', () => {
+ describe('canRender', () => {
+ it('should return true when the argument `literal` has font awesome inline html syntax', () => {
+ expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true);
+ });
+
+ it('should return false when the argument `literal` lacks font awesome inline html syntax', () => {
+ expect(renderer.canRender(normalTextNode)).toBe(false);
+ });
+ });
+
+ describe('render', () => {
+ it('should return uneditable inline tokens', () => {
+ const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal };
+ const context = { origin: () => token };
+
+ expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual(
+ buildUneditableInlineTokens(token),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
new file mode 100644
index 00000000000..a6c712eeb31
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
@@ -0,0 +1,38 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block';
+import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { normalTextNode } from './mock_data';
+
+const htmlBlockNode = {
+ firstChild: null,
+ literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
+ type: 'htmlBlock',
+};
+
+describe('Render HTML renderer', () => {
+ describe('canRender', () => {
+ it('should return true when the argument is an html block', () => {
+ expect(renderer.canRender(htmlBlockNode)).toBe(true);
+ });
+
+ it('should return false when the argument is not an html block', () => {
+ expect(renderer.canRender(normalTextNode)).toBe(false);
+ });
+ });
+
+ describe('render', () => {
+ const htmlBlockNodeToMark = {
+ firstChild: null,
+ literal: '<div data-to-mark ></div>',
+ type: 'htmlBlock',
+ };
+
+ it.each`
+ node
+ ${htmlBlockNode}
+ ${htmlBlockNodeToMark}
+ `('should return uneditable tokens wrapping the $node as a token', ({ node }) => {
+ expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node));
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js
new file mode 100644
index 00000000000..2897929f1bf
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js
@@ -0,0 +1,55 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text';
+import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { buildMockTextNode, normalTextNode } from './mock_data';
+
+const mockTextStart = 'Majority example ';
+const mockTextMiddle = '[environment terraform plans][terraform]';
+const mockTextEnd = '.';
+const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart);
+const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd);
+
+describe('Render Identifier Instance Text renderer', () => {
+ describe('canRender', () => {
+ it.each`
+ node | target
+ ${normalTextNode} | ${false}
+ ${identifierInstanceStartTextNode} | ${false}
+ ${identifierInstanceEndTextNode} | ${false}
+ ${buildMockTextNode(mockTextMiddle)} | ${true}
+ ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true}
+ ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true}
+ `(
+ 'should return $target when the $node validates against identifier instance syntax',
+ ({ node, target }) => {
+ expect(renderer.canRender(node)).toBe(target);
+ },
+ );
+ });
+
+ describe('render', () => {
+ it.each`
+ start | middle | end
+ ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd}
+ ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd}
+ ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd}
+ `(
+ 'should return inline editable, uneditable, and editable tokens in sequence',
+ ({ start, middle, end }) => {
+ const buildMockTextToken = content => ({ type: 'text', tagName: null, content });
+
+ const startToken = buildMockTextToken(start);
+ const middleToken = buildMockTextToken(middle);
+ const endToken = buildMockTextToken(end);
+
+ const content = `${start}${middle}${end}`;
+ const contentToken = buildMockTextToken(content);
+ const contentNode = buildMockTextNode(content);
+ const context = { origin: jest.fn().mockReturnValueOnce(contentToken) };
+ expect(renderer.render(contentNode, context)).toStrictEqual(
+ [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(),
+ );
+ },
+ );
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
new file mode 100644
index 00000000000..320589e4de3
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
@@ -0,0 +1,65 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph';
+import {
+ buildUneditableOpenTokens,
+ buildUneditableCloseToken,
+} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { buildMockTextNode } from './mock_data';
+
+const buildMockParagraphNode = literal => {
+ return {
+ firstChild: buildMockTextNode(literal),
+ type: 'paragraph',
+ };
+};
+
+const normalParagraphNode = buildMockParagraphNode(
+ 'This is just normal paragraph. It has multiple sentences.',
+);
+const identifierParagraphNode = buildMockParagraphNode(
+ `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`,
+);
+
+describe('Render Identifier Paragraph renderer', () => {
+ describe('canRender', () => {
+ it.each`
+ node | paragraph | target
+ ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true}
+ ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false}
+ `(
+ 'should return $target when the $node matches $paragraph syntax',
+ ({ node, paragraph, target }) => {
+ const context = {
+ entering: true,
+ getChildrenText: jest.fn().mockReturnValueOnce(paragraph),
+ };
+
+ expect(renderer.canRender(node, context)).toBe(target);
+ },
+ );
+ });
+
+ describe('render', () => {
+ let origin;
+
+ beforeEach(() => {
+ origin = jest.fn();
+ });
+
+ it('should return uneditable open tokens when entering', () => {
+ const context = { entering: true, origin };
+
+ expect(renderer.render(identifierParagraphNode, context)).toStrictEqual(
+ buildUneditableOpenTokens(origin()),
+ );
+ });
+
+ it('should return an uneditable close tokens when exiting', () => {
+ const context = { entering: false, origin };
+
+ expect(renderer.render(identifierParagraphNode, context)).toStrictEqual(
+ buildUneditableCloseToken(origin()),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js
new file mode 100644
index 00000000000..e60bf1c8c92
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js
@@ -0,0 +1,55 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list';
+import {
+ buildUneditableOpenTokens,
+ buildUneditableCloseToken,
+} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { buildMockTextNode } from './mock_data';
+
+const buildMockListNode = literal => {
+ return {
+ firstChild: {
+ firstChild: {
+ firstChild: buildMockTextNode(literal),
+ type: 'paragraph',
+ },
+ type: 'item',
+ },
+ type: 'list',
+ };
+};
+
+const normalListNode = buildMockListNode('Just another bullet point');
+const kramdownListNode = buildMockListNode('TOC');
+
+describe('Render Kramdown List renderer', () => {
+ describe('canRender', () => {
+ it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => {
+ expect(renderer.canRender(kramdownListNode)).toBe(true);
+ });
+
+ it('should return false when the argument is a normal ordered/unordered list', () => {
+ expect(renderer.canRender(normalListNode)).toBe(false);
+ });
+ });
+
+ describe('render', () => {
+ const origin = jest.fn();
+
+ it('should return uneditable open tokens when entering', () => {
+ const context = { entering: true, origin };
+
+ expect(renderer.render(kramdownListNode, context)).toStrictEqual(
+ buildUneditableOpenTokens(origin()),
+ );
+ });
+
+ it('should return an uneditable close tokens when exiting', () => {
+ const context = { entering: false, origin };
+
+ expect(renderer.render(kramdownListNode, context)).toStrictEqual(
+ buildUneditableCloseToken(origin()),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js
new file mode 100644
index 00000000000..97ff9794e69
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js
@@ -0,0 +1,30 @@
+import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text';
+import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
+
+import { buildMockTextNode, normalTextNode } from './mock_data';
+
+const kramdownTextNode = buildMockTextNode('{:toc}');
+
+describe('Render Kramdown Text renderer', () => {
+ describe('canRender', () => {
+ it('should return true when the argument `literal` has kramdown syntax', () => {
+ expect(renderer.canRender(kramdownTextNode)).toBe(true);
+ });
+
+ it('should return false when the argument `literal` lacks kramdown syntax', () => {
+ expect(renderer.canRender(normalTextNode)).toBe(false);
+ });
+ });
+
+ describe('render', () => {
+ const origin = jest.fn();
+
+ it('should return uneditable tokens', () => {
+ const context = { origin };
+
+ expect(renderer.render(kramdownTextNode, context)).toStrictEqual(
+ buildUneditableTokens(origin()),
+ );
+ });
+ });
+});