diff options
Diffstat (limited to 'spec/frontend/static_site_editor/rich_content_editor')
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); + }); + }); +}); |