summaryrefslogtreecommitdiff
path: root/spec/frontend/static_site_editor/rich_content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/static_site_editor/rich_content_editor')
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js214
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js73
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js41
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js44
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js69
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js222
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js32
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js218
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js88
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js54
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js25
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js24
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js33
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js12
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js37
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js55
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js84
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js12
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js23
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js109
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js11
-rw-r--r--spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js57
22 files changed, 1537 insertions, 0 deletions
diff --git a/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js
new file mode 100644
index 00000000000..cd0d09c085f
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/editor_service_spec.js
@@ -0,0 +1,214 @@
+import buildCustomRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer';
+import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer';
+import {
+ generateToolbarItem,
+ addCustomEventListener,
+ removeCustomEventListener,
+ registerHTMLToMarkdownRenderer,
+ addImage,
+ insertVideo,
+ getMarkdown,
+ getEditorOptions,
+} from '~/static_site_editor/rich_content_editor/services/editor_service';
+import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html';
+
+jest.mock('~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer');
+jest.mock('~/static_site_editor/rich_content_editor/services/build_custom_renderer');
+jest.mock('~/static_site_editor/rich_content_editor/services/sanitize_html');
+
+describe('Editor Service', () => {
+ let mockInstance;
+ let event;
+ let handler;
+ const parseHtml = (str) => {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = str;
+ return wrapper.firstChild;
+ };
+
+ beforeEach(() => {
+ mockInstance = {
+ eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
+ editor: {
+ exec: jest.fn(),
+ isWysiwygMode: jest.fn(),
+ getSquire: jest.fn(),
+ insertText: jest.fn(),
+ },
+ invoke: jest.fn(),
+ toMarkOptions: {
+ renderer: {
+ constructor: {
+ factory: jest.fn(),
+ },
+ },
+ },
+ };
+ event = 'someCustomEvent';
+ handler = jest.fn();
+ });
+
+ describe('generateToolbarItem', () => {
+ const config = {
+ icon: 'bold',
+ command: 'some-command',
+ tooltip: 'Some Tooltip',
+ event: 'some-event',
+ };
+
+ const generatedItem = generateToolbarItem(config);
+
+ it('generates the correct command', () => {
+ expect(generatedItem.options.command).toBe(config.command);
+ });
+
+ it('generates the correct event', () => {
+ expect(generatedItem.options.event).toBe(config.event);
+ });
+
+ it('generates a divider when isDivider is set to true', () => {
+ const isDivider = true;
+
+ expect(generateToolbarItem({ isDivider })).toBe('divider');
+ });
+ });
+
+ describe('addCustomEventListener', () => {
+ it('registers an event type on the instance and adds an event handler', () => {
+ addCustomEventListener(mockInstance, event, handler);
+
+ expect(mockInstance.eventManager.addEventType).toHaveBeenCalledWith(event);
+ expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler);
+ });
+ });
+
+ describe('removeCustomEventListener', () => {
+ it('removes an event handler from the instance', () => {
+ removeCustomEventListener(mockInstance, event, handler);
+
+ expect(mockInstance.eventManager.removeEventHandler).toHaveBeenCalledWith(event, handler);
+ });
+ });
+
+ describe('addImage', () => {
+ const file = new File([], 'some-file.jpg');
+ const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' };
+
+ it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => {
+ jest.spyOn(URL, 'createObjectURL');
+ mockInstance.editor.isWysiwygMode.mockReturnValue(true);
+ mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() });
+
+ addImage(mockInstance, mockImage, file);
+
+ expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled();
+ expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file);
+ });
+
+ it('calls the insertText method on the instance when in Markdown mode', () => {
+ mockInstance.editor.isWysiwygMode.mockReturnValue(false);
+ addImage(mockInstance, mockImage, file);
+
+ expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)');
+ });
+ });
+
+ describe('insertVideo', () => {
+ const mockUrl = 'some/url';
+ const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`;
+ const mockInsertElement = jest.fn();
+
+ beforeEach(() =>
+ mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }),
+ );
+
+ describe('WYSIWYG mode', () => {
+ it('calls the insertElement method on the squire instance with an iFrame element', () => {
+ mockInstance.editor.isWysiwygMode.mockReturnValue(true);
+
+ insertVideo(mockInstance, mockUrl);
+
+ expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith(
+ parseHtml(htmlString),
+ );
+ });
+ });
+
+ describe('Markdown mode', () => {
+ it('calls the insertText method on the editor instance with the iFrame element HTML', () => {
+ mockInstance.editor.isWysiwygMode.mockReturnValue(false);
+
+ insertVideo(mockInstance, mockUrl);
+
+ expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString);
+ });
+ });
+ });
+
+ describe('getMarkdown', () => {
+ it('calls the invoke method on the instance', () => {
+ getMarkdown(mockInstance);
+
+ 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);
+ });
+ });
+
+ describe('getEditorOptions', () => {
+ const externalOptions = {
+ customRenderers: {},
+ };
+ const renderer = {};
+
+ beforeEach(() => {
+ buildCustomRenderer.mockReturnValueOnce(renderer);
+ });
+
+ it('generates a configuration object with a custom HTML renderer and toolbarItems', () => {
+ expect(getEditorOptions()).toHaveProp('customHTMLRenderer', renderer);
+ expect(getEditorOptions()).toHaveProp('toolbarItems');
+ });
+
+ it('passes external renderers to the buildCustomRenderers function', () => {
+ getEditorOptions(externalOptions);
+ expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers);
+ });
+
+ it('uses the internal sanitizeHTML service for HTML sanitization', () => {
+ const options = getEditorOptions();
+ const html = '<div></div>';
+
+ options.customHTMLSanitizer(html);
+
+ expect(sanitizeHTML).toHaveBeenCalledWith(html);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js
new file mode 100644
index 00000000000..86ae016987d
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/add_image_modal_spec.js
@@ -0,0 +1,73 @@
+import { GlModal, GlTabs } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { IMAGE_TABS } from '~/static_site_editor/rich_content_editor/constants';
+import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue';
+import UploadImageTab from '~/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue';
+
+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, { 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/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js
new file mode 100644
index 00000000000..11b73d58259
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab_spec.js
@@ -0,0 +1,41 @@
+import { shallowMount } from '@vue/test-utils';
+import UploadImageTab from '~/static_site_editor/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/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js
new file mode 100644
index 00000000000..392d31bf039
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/modals/insert_video_modal_spec.js
@@ -0,0 +1,44 @@
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue';
+
+describe('Insert Video Modal', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.find(GlModal);
+ const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
+
+ const triggerInsertVideo = (url) => {
+ const preventDefault = jest.fn();
+ findUrlInput().vm.$emit('input', url);
+ findModal().vm.$emit('primary', { preventDefault });
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(InsertVideoModal);
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ describe('when content is loaded', () => {
+ it('renders a modal component', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('renders an input to add a URL', () => {
+ expect(findUrlInput().exists()).toBe(true);
+ });
+ });
+
+ describe('insert video', () => {
+ it.each`
+ url | emitted
+ ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]}
+ ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]}
+ ${'::youtube.com/invalid/url'} | ${undefined}
+ `('formats the url correctly', ({ url, emitted }) => {
+ triggerInsertVideo(url);
+ expect(wrapper.emitted('insertVideo')).toEqual(emitted);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js
new file mode 100644
index 00000000000..6c02ec506c6
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_integration_spec.js
@@ -0,0 +1,69 @@
+import Editor from '@toast-ui/editor';
+import buildMarkdownToHTMLRenderer from '~/static_site_editor/rich_content_editor/services/build_custom_renderer';
+import { registerHTMLToMarkdownRenderer } from '~/static_site_editor/rich_content_editor/services/editor_service';
+
+describe('static_site_editor/rich_content_editor', () => {
+ let editor;
+
+ const buildEditor = () => {
+ editor = new Editor({
+ el: document.body,
+ customHTMLRenderer: buildMarkdownToHTMLRenderer(),
+ });
+
+ registerHTMLToMarkdownRenderer(editor);
+ };
+
+ beforeEach(() => {
+ buildEditor();
+ });
+
+ describe('HTML to Markdown', () => {
+ it('uses "-" character list marker in unordered lists', () => {
+ editor.setHtml('<ul><li>List item 1</li><li>List item 2</li></ul>');
+
+ const markdown = editor.getMarkdown();
+
+ expect(markdown).toBe('- List item 1\n- List item 2');
+ });
+
+ it('does not increment the list marker in ordered lists', () => {
+ editor.setHtml('<ol><li>List item 1</li><li>List item 2</li></ol>');
+
+ const markdown = editor.getMarkdown();
+
+ expect(markdown).toBe('1. List item 1\n1. List item 2');
+ });
+
+ it('indents lists using four spaces', () => {
+ editor.setHtml('<ul><li>List item 1</li><ul><li>List item 2</li></ul></ul>');
+
+ const markdown = editor.getMarkdown();
+
+ expect(markdown).toBe('- List item 1\n - List item 2');
+ });
+
+ it('uses * for strong and _ for emphasis text', () => {
+ editor.setHtml('<strong>strong text</strong><i>emphasis text</i>');
+
+ const markdown = editor.getMarkdown();
+
+ expect(markdown).toBe('**strong text**_emphasis text_');
+ });
+ });
+
+ describe('Markdown to HTML', () => {
+ it.each`
+ input | output
+ ${'markdown with _emphasized\ntext_'} | ${'<p>markdown with <em>emphasized text</em></p>\n'}
+ ${'markdown with **strong\ntext**'} | ${'<p>markdown with <strong>strong text</strong></p>\n'}
+ `(
+ 'does not transform softbreaks inside (_) and strong (**) nodes into <br/> tags',
+ ({ input, output }) => {
+ editor.setMarkdown(input);
+
+ expect(editor.getHtml()).toBe(output);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js
new file mode 100644
index 00000000000..3b0d2993a5d
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/rich_content_editor_spec.js
@@ -0,0 +1,222 @@
+import { Editor, mockEditorApi } from '@toast-ui/vue-editor';
+import { shallowMount } from '@vue/test-utils';
+import {
+ EDITOR_TYPES,
+ EDITOR_HEIGHT,
+ EDITOR_PREVIEW_STYLE,
+ CUSTOM_EVENTS,
+} from '~/static_site_editor/rich_content_editor/constants';
+import AddImageModal from '~/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue';
+import InsertVideoModal from '~/static_site_editor/rich_content_editor/modals/insert_video_modal.vue';
+import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue';
+
+import {
+ addCustomEventListener,
+ removeCustomEventListener,
+ addImage,
+ insertVideo,
+ registerHTMLToMarkdownRenderer,
+ getEditorOptions,
+ getMarkdown,
+} from '~/static_site_editor/rich_content_editor/services/editor_service';
+
+jest.mock('~/static_site_editor/rich_content_editor/services/editor_service', () => ({
+ addCustomEventListener: jest.fn(),
+ removeCustomEventListener: jest.fn(),
+ addImage: jest.fn(),
+ insertVideo: jest.fn(),
+ registerHTMLToMarkdownRenderer: jest.fn(),
+ getEditorOptions: jest.fn(),
+ getMarkdown: jest.fn(),
+}));
+
+describe('Rich Content Editor', () => {
+ let wrapper;
+
+ const content = '## Some Markdown';
+ const imageRoot = 'path/to/root/';
+ const findEditor = () => wrapper.find({ ref: 'editor' });
+ const findAddImageModal = () => wrapper.find(AddImageModal);
+ const findInsertVideoModal = () => wrapper.find(InsertVideoModal);
+
+ const buildWrapper = async () => {
+ wrapper = shallowMount(RichContentEditor, {
+ propsData: { content, imageRoot },
+ stubs: {
+ ToastEditor: Editor,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when content is loaded', () => {
+ const editorOptions = {};
+
+ beforeEach(() => {
+ getEditorOptions.mockReturnValueOnce(editorOptions);
+ buildWrapper();
+ });
+
+ it('renders an editor', () => {
+ expect(findEditor().exists()).toBe(true);
+ });
+
+ it('renders the correct content', () => {
+ expect(findEditor().props().initialValue).toBe(content);
+ });
+
+ it('provides options generated by the getEditorOptions service', () => {
+ expect(findEditor().props().options).toBe(editorOptions);
+ });
+
+ it('has the correct preview style', () => {
+ expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE);
+ });
+
+ it('has the correct initial edit type', () => {
+ expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg);
+ });
+
+ it('has the correct height', () => {
+ expect(findEditor().props().height).toBe(EDITOR_HEIGHT);
+ });
+ });
+
+ describe('when content is changed', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('emits an input event with the changed content', () => {
+ const changedMarkdown = '## Changed Markdown';
+ getMarkdown.mockReturnValueOnce(changedMarkdown);
+
+ findEditor().vm.$emit('change');
+
+ expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown);
+ });
+ });
+
+ describe('when content is reset', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ 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', () => {
+ const formattedMarkdown = 'formatted markdown';
+
+ beforeEach(() => {
+ mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown);
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ mockEditorApi.getMarkdown.mockReset();
+ });
+
+ it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
+ expect(addCustomEventListener).toHaveBeenCalledWith(
+ wrapper.vm.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ wrapper.vm.onOpenAddImageModal,
+ );
+ });
+
+ it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
+ expect(addCustomEventListener).toHaveBeenCalledWith(
+ wrapper.vm.editorApi,
+ CUSTOM_EVENTS.openInsertVideoModal,
+ wrapper.vm.onOpenInsertVideoModal,
+ );
+ });
+
+ it('registers HTML to markdown renderer', () => {
+ expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi);
+ });
+
+ it('emits load event with the markdown formatted by Toast UI', () => {
+ mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown);
+ expect(mockEditorApi.getMarkdown).toHaveBeenCalled();
+ expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]);
+ });
+ });
+
+ describe('when editor is destroyed', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
+ wrapper.vm.$destroy();
+
+ expect(removeCustomEventListener).toHaveBeenCalledWith(
+ wrapper.vm.editorApi,
+ CUSTOM_EVENTS.openAddImageModal,
+ wrapper.vm.onOpenAddImageModal,
+ );
+ });
+
+ it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
+ wrapper.vm.$destroy();
+
+ expect(removeCustomEventListener).toHaveBeenCalledWith(
+ wrapper.vm.editorApi,
+ CUSTOM_EVENTS.openInsertVideoModal,
+ wrapper.vm.onOpenInsertVideoModal,
+ );
+ });
+ });
+
+ describe('add image modal', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders an addImageModal component', () => {
+ expect(findAddImageModal().exists()).toBe(true);
+ });
+
+ it('calls the onAddImage method when the addImage event is emitted', () => {
+ const mockImage = { imageUrl: 'some/url.png', altText: 'some description' };
+ const mockInstance = { exec: jest.fn() };
+ wrapper.vm.$refs.editor = mockInstance;
+
+ findAddImageModal().vm.$emit('addImage', mockImage);
+ expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined);
+ });
+ });
+
+ describe('insert video modal', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders an insertVideoModal component', () => {
+ expect(findInsertVideoModal().exists()).toBe(true);
+ });
+
+ it('calls the onInsertVideo method when the insertVideo event is emitted', () => {
+ const mockUrl = 'https://www.youtube.com/embed/someId';
+ const mockInstance = { exec: jest.fn() };
+ wrapper.vm.$refs.editor = mockInstance;
+
+ findInsertVideoModal().vm.$emit('insertVideo', mockUrl);
+ expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js
new file mode 100644
index 00000000000..202e13e8bff
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/build_custom_renderer_spec.js
@@ -0,0 +1,32 @@
+import buildCustomHTMLRenderer from '~/static_site_editor/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({
+ htmlBlock: expect.any(Function),
+ htmlInline: expect.any(Function),
+ heading: expect.any(Function),
+ item: expect.any(Function),
+ paragraph: expect.any(Function),
+ text: expect.any(Function),
+ softbreak: 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),
+ }),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
new file mode 100644
index 00000000000..c9cba3e8689
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
@@ -0,0 +1,218 @@
+import buildHTMLToMarkdownRenderer from '~/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer';
+import { attributeDefinition } from './renderers/mock_data';
+
+describe('rich_content_editor/services/html_to_markdown_renderer', () => {
+ let baseRenderer;
+ let htmlToMarkdownRenderer;
+ let fakeNode;
+
+ 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(),
+ };
+
+ fakeNode = { nodeValue: 'mock_node', dataset: {} };
+ });
+
+ afterEach(() => {
+ htmlToMarkdownRenderer = null;
+ });
+
+ describe('TEXT_NODE visitor', () => {
+ it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+
+ expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe(
+ `space controlled trimmed space collapsed ${fakeNode.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'](fakeNode, list)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list);
+ });
+ });
+
+ describe('UL LI visitor', () => {
+ it.each`
+ listItem | unorderedListBulletChar | result | bulletChar
+ ${'* list item'} | ${undefined} | ${'- list item'} | ${'default'}
+ ${' - list item'} | ${'*'} | ${' * list item'} | ${'*'}
+ ${' * list item'} | ${'-'} | ${' - list item'} | ${'-'}
+ `(
+ 'uses $bulletChar bullet char in unordered list items when $unorderedListBulletChar is set in config',
+ ({ listItem, unorderedListBulletChar, result }) => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, {
+ unorderedListBulletChar,
+ });
+ baseRenderer.convert.mockReturnValueOnce(listItem);
+
+ expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem);
+ },
+ );
+
+ it('detects attribute definitions and attaches them to the list item', () => {
+ const listItem = '- list item';
+ const result = `${listItem}\n${attributeDefinition}\n`;
+
+ fakeNode.dataset.attributeDefinition = attributeDefinition;
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`);
+
+ expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result);
+ });
+ });
+
+ describe('OL LI visitor', () => {
+ it.each`
+ listItem | result | incrementListMarker | action
+ ${'2. list item'} | ${'1. list item'} | ${false} | ${'increments'}
+ ${' 3. list item'} | ${' 1. list item'} | ${false} | ${'increments'}
+ ${' 123. list item'} | ${' 1. list item'} | ${false} | ${'increments'}
+ ${'3. list item'} | ${'3. list item'} | ${true} | ${'does not increment'}
+ `(
+ '$action a list item counter when incrementListMaker is $incrementListMarker',
+ ({ listItem, result, incrementListMarker }) => {
+ const subContent = null;
+
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, {
+ incrementListMarker,
+ });
+ baseRenderer.convert.mockReturnValueOnce(listItem);
+
+ expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent);
+ },
+ );
+ });
+
+ describe('STRONG, B visitor', () => {
+ it.each`
+ input | strongCharacter | result
+ ${'**strong text**'} | ${'_'} | ${'__strong text__'}
+ ${'__strong text__'} | ${'*'} | ${'**strong text**'}
+ `(
+ 'converts $input to $result when strong character is $strongCharacter',
+ ({ input, strongCharacter, result }) => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, {
+ strong: strongCharacter,
+ });
+
+ baseRenderer.convert.mockReturnValueOnce(input);
+
+ expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
+ },
+ );
+ });
+
+ describe('EM, I visitor', () => {
+ it.each`
+ input | emphasisCharacter | result
+ ${'*strong text*'} | ${'_'} | ${'_strong text_'}
+ ${'_strong text_'} | ${'*'} | ${'*strong text*'}
+ `(
+ 'converts $input to $result when emphasis character is $emphasisCharacter',
+ ({ input, emphasisCharacter, result }) => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, {
+ emphasis: emphasisCharacter,
+ });
+
+ baseRenderer.convert.mockReturnValueOnce(input);
+
+ expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result);
+ expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input);
+ },
+ );
+ });
+
+ describe('H1, H2, H3, H4, H5, H6 visitor', () => {
+ it('detects attribute definitions and attaches them to the heading', () => {
+ const heading = 'heading text';
+ const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`;
+
+ fakeNode.dataset.attributeDefinition = attributeDefinition;
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`);
+
+ expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result);
+ });
+ });
+
+ describe('PRE CODE', () => {
+ let node;
+ const subContent = 'sub content';
+ const originalConverterResult = 'base result';
+
+ beforeEach(() => {
+ node = document.createElement('PRE');
+
+ node.innerText = 'reference definition content';
+ node.dataset.sseReferenceDefinition = true;
+
+ baseRenderer.convert.mockReturnValueOnce(originalConverterResult);
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ });
+
+ it('returns raw text when pre node has sse-reference-definitions class', () => {
+ expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(
+ `\n\n${node.innerText}\n\n`,
+ );
+ });
+
+ it('returns base result when pre node does not have sse-reference-definitions class', () => {
+ delete node.dataset.sseReferenceDefinition;
+
+ expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult);
+ });
+ });
+
+ describe('IMG', () => {
+ const originalSrc = 'path/to/image.png';
+ const alt = 'alt text';
+ let node;
+
+ beforeEach(() => {
+ node = document.createElement('img');
+ node.alt = alt;
+ node.src = originalSrc;
+ });
+
+ it('returns an image with its original src of the `original-src` attribute is preset', () => {
+ node.dataset.originalSrc = originalSrc;
+ node.src = 'modified/path/to/image.png';
+
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+
+ expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`);
+ });
+
+ it('fallback to `src` if no `original-src` is specified on the image', () => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js
new file mode 100644
index 00000000000..ef3ff052cb2
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token_spec.js
@@ -0,0 +1,88 @@
+import {
+ buildTextToken,
+ buildUneditableOpenTokens,
+ buildUneditableCloseToken,
+ buildUneditableCloseTokens,
+ buildUneditableBlockTokens,
+ buildUneditableInlineTokens,
+ buildUneditableHtmlAsTextTokens,
+} from '~/static_site_editor/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('buildUneditableBlockTokens', () => {
+ it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => {
+ const result = buildUneditableBlockTokens(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/static_site_editor/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js
new file mode 100644
index 00000000000..407072fb596
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/mock_data.js
@@ -0,0 +1,54 @@
+// Node spec helpers
+
+export const buildMockTextNode = (literal) => ({ 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 buildMockTextToken = (content) => {
+ return {
+ type: 'text',
+ tagName: null,
+ content,
+ };
+};
+
+const buildMockUneditableCloseToken = (type) => ({ type: 'closeTag', tagName: type });
+
+export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}');
+const uneditableOpenToken = buildMockUneditableOpenToken('div');
+export const uneditableOpenTokens = [uneditableOpenToken, originToken];
+export const uneditableCloseToken = buildMockUneditableCloseToken('div');
+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 = [
+ uneditableOpenToken,
+ buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'),
+ uneditableCloseToken,
+];
+
+export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}';
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js
new file mode 100644
index 00000000000..6d96dd3bbca
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition_spec.js
@@ -0,0 +1,25 @@
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition';
+import { attributeDefinition } from './mock_data';
+
+describe('rich_content_editor/renderers/render_attribute_definition', () => {
+ describe('canRender', () => {
+ it.each`
+ input | result
+ ${{ literal: attributeDefinition }} | ${true}
+ ${{ literal: `FOO${attributeDefinition}` }} | ${false}
+ ${{ literal: `${attributeDefinition}BAR` }} | ${false}
+ ${{ literal: 'foobar' }} | ${false}
+ `('returns $result when input is $input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
+ });
+ });
+
+ describe('render', () => {
+ it('returns an empty HTML comment', () => {
+ expect(renderer.render()).toEqual({
+ type: 'html',
+ content: '<!-- sse-attribute-definition -->',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js
new file mode 100644
index 00000000000..29e2b5b3b16
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_spec.js
@@ -0,0 +1,24 @@
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text';
+import { renderUneditableLeaf } from '~/static_site_editor/rich_content_editor/services/renderers/render_utils';
+
+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', () => {
+ it('should delegate rendering to the renderUneditableLeaf util', () => {
+ expect(renderer.render).toBe(renderUneditableLeaf);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js
new file mode 100644
index 00000000000..0fda847b688
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js
@@ -0,0 +1,33 @@
+import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token';
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline';
+
+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/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js
new file mode 100644
index 00000000000..cf4a90885df
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_heading_spec.js
@@ -0,0 +1,12 @@
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_heading';
+import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils';
+
+describe('rich_content_editor/renderers/render_heading', () => {
+ it('canRender delegates to renderUtils.willAlwaysRender', () => {
+ expect(renderer.canRender).toBe(renderUtils.willAlwaysRender);
+ });
+
+ it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
+ expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js
new file mode 100644
index 00000000000..9c937ac22f4
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_html_block_spec.js
@@ -0,0 +1,37 @@
+import { buildUneditableHtmlAsTextTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token';
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_html_block';
+
+describe('rich_content_editor/services/renderers/render_html_block', () => {
+ const htmlBlockNode = {
+ literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
+ type: 'htmlBlock',
+ };
+
+ describe('canRender', () => {
+ it.each`
+ input | result
+ ${htmlBlockNode} | ${true}
+ ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true}
+ ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false}
+ ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false}
+ `('returns $result when input=$input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
+ });
+ });
+
+ 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/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js
new file mode 100644
index 00000000000..15fb2c3a430
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js
@@ -0,0 +1,55 @@
+import { buildUneditableInlineTokens } from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token';
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text';
+
+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/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
new file mode 100644
index 00000000000..6a2b89a8dcf
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js
@@ -0,0 +1,84 @@
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph';
+
+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('rich_content_editor/renderers_render_identifier_paragraph', () => {
+ 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 context;
+ let result;
+
+ beforeEach(() => {
+ const node = {
+ firstChild: {
+ type: 'text',
+ literal: '[Some text]: https://link.com',
+ next: {
+ type: 'linebreak',
+ next: {
+ type: 'text',
+ literal: '[identifier]: http://example1.com "title"',
+ },
+ },
+ },
+ };
+ context = { skipChildren: jest.fn() };
+ result = renderer.render(node, context);
+ });
+
+ it('renders the reference definitions as a code block', () => {
+ expect(result).toEqual([
+ {
+ type: 'openTag',
+ tagName: 'pre',
+ classNames: ['code-block', 'language-markdown'],
+ attributes: {
+ 'data-sse-reference-definition': true,
+ },
+ },
+ { type: 'openTag', tagName: 'code' },
+ {
+ type: 'text',
+ content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"',
+ },
+ { type: 'closeTag', tagName: 'code' },
+ { type: 'closeTag', tagName: 'pre' },
+ ]);
+ });
+
+ it('skips the reference definition node children from rendering', () => {
+ expect(context.skipChildren).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js
new file mode 100644
index 00000000000..1e8e62b9dd2
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_list_item_spec.js
@@ -0,0 +1,12 @@
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_list_item';
+import * as renderUtils from '~/static_site_editor/rich_content_editor/services/renderers/render_utils';
+
+describe('rich_content_editor/renderers/render_list_item', () => {
+ it('canRender delegates to renderUtils.willAlwaysRender', () => {
+ expect(renderer.canRender).toBe(renderUtils.willAlwaysRender);
+ });
+
+ it('render delegates to renderUtils.renderWithAttributeDefinitions', () => {
+ expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions);
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js
new file mode 100644
index 00000000000..d8d1e6ff295
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_softbreak_spec.js
@@ -0,0 +1,23 @@
+import renderer from '~/static_site_editor/rich_content_editor/services/renderers/render_softbreak';
+
+describe('Render softbreak renderer', () => {
+ describe('canRender', () => {
+ it.each`
+ node | parentType | result
+ ${{ parent: { type: 'emph' } }} | ${'emph'} | ${true}
+ ${{ parent: { type: 'strong' } }} | ${'strong'} | ${true}
+ ${{ parent: { type: 'paragraph' } }} | ${'paragraph'} | ${false}
+ `('returns $result when node parent type is $parentType ', ({ node, result }) => {
+ expect(renderer.canRender(node)).toBe(result);
+ });
+ });
+
+ describe('render', () => {
+ it('returns text node with a break line', () => {
+ expect(renderer.render()).toEqual({
+ type: 'text',
+ content: ' ',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js
new file mode 100644
index 00000000000..49b8936a9f7
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/renderers/render_utils_spec.js
@@ -0,0 +1,109 @@
+import {
+ buildUneditableBlockTokens,
+ buildUneditableOpenTokens,
+} from '~/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token';
+import {
+ renderUneditableLeaf,
+ renderUneditableBranch,
+ renderWithAttributeDefinitions,
+ willAlwaysRender,
+} from '~/static_site_editor/rich_content_editor/services/renderers/render_utils';
+
+import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data';
+
+describe('rich_content_editor/renderers/render_utils', () => {
+ describe('renderUneditableLeaf', () => {
+ it('should return uneditable block tokens around an origin token', () => {
+ const context = { origin: jest.fn().mockReturnValueOnce(originToken) };
+ const result = renderUneditableLeaf({}, context);
+
+ expect(result).toStrictEqual(buildUneditableBlockTokens(originToken));
+ });
+ });
+
+ describe('renderUneditableBranch', () => {
+ let origin;
+
+ beforeEach(() => {
+ origin = jest.fn().mockReturnValueOnce(originToken);
+ });
+
+ it('should return uneditable block open token followed by the origin token when entering', () => {
+ const context = { entering: true, origin };
+ const result = renderUneditableBranch({}, context);
+
+ expect(result).toStrictEqual(buildUneditableOpenTokens(originToken));
+ });
+
+ it('should return uneditable block closing token when exiting', () => {
+ const context = { entering: false, origin };
+ const result = renderUneditableBranch({}, context);
+
+ expect(result).toStrictEqual(uneditableCloseToken);
+ });
+ });
+
+ describe('willAlwaysRender', () => {
+ it('always returns true', () => {
+ expect(willAlwaysRender()).toBe(true);
+ });
+ });
+
+ describe('renderWithAttributeDefinitions', () => {
+ let openTagToken;
+ let closeTagToken;
+ let node;
+ const attributes = {
+ 'data-attribute-definition': attributeDefinition,
+ };
+
+ beforeEach(() => {
+ openTagToken = { type: 'openTag' };
+ closeTagToken = { type: 'closeTag' };
+ node = {
+ next: {
+ firstChild: {
+ literal: attributeDefinition,
+ },
+ },
+ };
+ });
+
+ describe('when token type is openTag', () => {
+ it('attaches attributes when attributes exist in the node’s next sibling', () => {
+ const context = { origin: () => openTagToken };
+
+ expect(renderWithAttributeDefinitions(node, context)).toEqual({
+ ...openTagToken,
+ attributes,
+ });
+ });
+
+ it('attaches attributes when attributes exist in the node’s children', () => {
+ const context = { origin: () => openTagToken };
+ node = {
+ firstChild: {
+ firstChild: {
+ next: {
+ next: {
+ literal: attributeDefinition,
+ },
+ },
+ },
+ },
+ };
+
+ expect(renderWithAttributeDefinitions(node, context)).toEqual({
+ ...openTagToken,
+ attributes,
+ });
+ });
+ });
+
+ it('does not attach attributes when token type is "closeTag"', () => {
+ const context = { origin: () => closeTagToken };
+
+ expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken);
+ });
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js
new file mode 100644
index 00000000000..2f2d3beb53d
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/services/sanitize_html_spec.js
@@ -0,0 +1,11 @@
+import sanitizeHTML from '~/static_site_editor/rich_content_editor/services/sanitize_html';
+
+describe('rich_content_editor/services/sanitize_html', () => {
+ it.each`
+ input | result
+ ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'}
+ ${'<iframe src="https://gitlab.com"></iframe>'} | ${''}
+ `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => {
+ expect(sanitizeHTML(input)).toBe(result);
+ });
+});
diff --git a/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js
new file mode 100644
index 00000000000..c9dcf9cfe2e
--- /dev/null
+++ b/spec/frontend/static_site_editor/rich_content_editor/toolbar_item_spec.js
@@ -0,0 +1,57 @@
+import { GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ToolbarItem from '~/static_site_editor/rich_content_editor/toolbar_item.vue';
+
+describe('Toolbar Item', () => {
+ let wrapper;
+
+ const findIcon = () => wrapper.find(GlIcon);
+ const findButton = () => wrapper.find('button');
+
+ const buildWrapper = (propsData) => {
+ wrapper = shallowMount(ToolbarItem, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ describe.each`
+ icon | tooltip
+ ${'heading'} | ${'Headings'}
+ ${'bold'} | ${'Add bold text'}
+ ${'italic'} | ${'Add italic text'}
+ ${'strikethrough'} | ${'Add strikethrough text'}
+ ${'quote'} | ${'Insert a quote'}
+ ${'link'} | ${'Add a link'}
+ ${'doc-code'} | ${'Insert a code block'}
+ ${'list-bulleted'} | ${'Add a bullet list'}
+ ${'list-numbered'} | ${'Add a numbered list'}
+ ${'list-task'} | ${'Add a task list'}
+ ${'list-indent'} | ${'Indent'}
+ ${'list-outdent'} | ${'Outdent'}
+ ${'dash'} | ${'Add a line'}
+ ${'table'} | ${'Add a table'}
+ ${'code'} | ${'Insert an image'}
+ ${'code'} | ${'Insert inline code'}
+ `('toolbar item component', ({ icon, tooltip }) => {
+ beforeEach(() => buildWrapper({ icon, tooltip }));
+
+ it('renders a toolbar button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it('renders the correct tooltip', () => {
+ const buttonTooltip = getBinding(wrapper.element, 'gl-tooltip');
+ expect(buttonTooltip).toBeDefined();
+ expect(buttonTooltip.value.title).toBe(tooltip);
+ });
+
+ it(`renders the ${icon} icon`, () => {
+ expect(findIcon().exists()).toBe(true);
+ expect(findIcon().props().name).toBe(icon);
+ });
+ });
+});