diff options
Diffstat (limited to 'spec/frontend/content_editor')
-rw-r--r-- | spec/frontend/content_editor/components/code_block_bubble_menu_spec.js | 142 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/formatting_bubble_menu_spec.js | 2 | ||||
-rw-r--r-- | spec/frontend/content_editor/components/wrappers/media_spec.js (renamed from spec/frontend/content_editor/components/wrappers/image_spec.js) | 21 | ||||
-rw-r--r-- | spec/frontend/content_editor/extensions/attachment_spec.js | 79 | ||||
-rw-r--r-- | spec/frontend/content_editor/extensions/code_block_highlight_spec.js | 74 | ||||
-rw-r--r-- | spec/frontend/content_editor/extensions/frontmatter_spec.js | 2 | ||||
-rw-r--r-- | spec/frontend/content_editor/services/code_block_language_loader_spec.js | 120 | ||||
-rw-r--r-- | spec/frontend/content_editor/services/content_editor_spec.js | 24 |
8 files changed, 412 insertions, 52 deletions
diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js new file mode 100644 index 00000000000..074c311495f --- /dev/null +++ b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js @@ -0,0 +1,142 @@ +import { BubbleMenu } from '@tiptap/vue-2'; +import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; +import { createTestEditor, emitEditorEvent } from '../test_utils'; + +describe('content_editor/components/code_block_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(CodeBlockBubbleMenu, { + provide: { + tiptapEditor, + eventHub, + }, + }); + }; + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemsData = () => + findDropdownItems().wrappers.map((x) => ({ + text: x.text(), + visible: x.isVisible(), + checked: x.props('isChecked'), + })); + + beforeEach(() => { + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + tiptapEditor.commands.insertContent('<pre>test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + }); + + it('selects plaintext language by default', async () => { + tiptapEditor.commands.insertContent('<pre>test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text'); + }); + + it('selects appropriate language based on the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript'); + }); + + it("selects Custom (syntax) if the language doesn't exist in the list", async () => { + tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>'); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)'); + }); + + it('delete button deletes the code block', async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(tiptapEditor.getText()).toBe(''); + }); + + describe('when opened and search is changed', () => { + beforeEach(async () => { + tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>'); + + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); + + await Vue.nextTick(); + }); + + it('shows dropdown items', () => { + expect(findDropdownItemsData()).toEqual([ + { text: 'Javascript', visible: true, checked: true }, + { text: 'Java', visible: true, checked: false }, + { text: 'Javascript', visible: false, checked: false }, + { text: 'JSON', visible: true, checked: false }, + ]); + }); + + describe('when dropdown item is clicked', () => { + beforeEach(async () => { + jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue(); + + findDropdownItems().at(1).vm.$emit('click'); + + await Vue.nextTick(); + }); + + it('loads language', () => { + expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']); + }); + + it('sets code block', () => { + expect(tiptapEditor.getJSON()).toMatchObject({ + content: [ + { + type: 'codeBlock', + attrs: { + language: 'java', + }, + }, + ], + }); + }); + + it('updates selected dropdown', () => { + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java'); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js index e44a7fa4ddb..192ddee78c6 100644 --- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js @@ -9,7 +9,7 @@ import { } from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; -describe('content_editor/components/top_toolbar', () => { +describe('content_editor/components/formatting_bubble_menu', () => { let wrapper; let trackingSpy; let tiptapEditor; diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js index 7b057f9cabc..3e95e2f3914 100644 --- a/spec/frontend/content_editor/components/wrappers/image_spec.js +++ b/spec/frontend/content_editor/components/wrappers/media_spec.js @@ -1,21 +1,24 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { NodeViewWrapper } from '@tiptap/vue-2'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; +import MediaWrapper from '~/content_editor/components/wrappers/media.vue'; -describe('content/components/wrappers/image', () => { +describe('content/components/wrappers/media', () => { let wrapper; const createWrapper = async (nodeAttrs = {}) => { - wrapper = shallowMountExtended(ImageWrapper, { + wrapper = shallowMountExtended(MediaWrapper, { propsData: { node: { attrs: nodeAttrs, + type: { + name: 'image', + }, }, }, }); }; - const findImage = () => wrapper.findByTestId('image'); + const findMedia = () => wrapper.findByTestId('media'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); afterEach(() => { @@ -33,7 +36,7 @@ describe('content/components/wrappers/image', () => { createWrapper({ src }); - expect(findImage().attributes().src).toBe(src); + expect(findMedia().attributes().src).toBe(src); }); describe('when uploading', () => { @@ -45,8 +48,8 @@ describe('content/components/wrappers/image', () => { expect(findLoadingIcon().exists()).toBe(true); }); - it('adds gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).toContain('gl-opacity-5'); + it('adds gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).toContain('gl-opacity-5'); }); }); @@ -59,8 +62,8 @@ describe('content/components/wrappers/image', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('does not add gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).not.toContain('gl-opacity-5'); + it('does not add gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).not.toContain('gl-opacity-5'); }); }); }); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index ec67545cf17..d3c42104e47 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,7 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; +import Audio from '~/content_editor/extensions/audio'; +import Video from '~/content_editor/extensions/video'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import { VARIANT_DANGER } from '~/flash'; @@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="au <img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png"> </a> </p>`; + +const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto"> + <span class="media-container video-container"> + <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4"> + </video> + <a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a> + </span> +</p>`; + +const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto"> + <span class="media-container audio-container"> + <audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3"> + </audio> + <a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a> + </span> +</p>`; + const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto"> <a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a> </p>`; @@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => { let doc; let p; let image; + let audio; + let video; let loading; let link; let renderMarkdown; @@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => { const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); + const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' }); + const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 1; - const handleTransaction = () => { + const handleTransaction = async () => { if (counter === number) { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); tiptapEditor.off('update', handleTransaction); + await waitForPromises(); resolve(); } @@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => { Loading, Link, Image, + Audio, + Video, Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), ], }); ({ - builders: { doc, p, image, loading, link }, + builders: { doc, p, image, audio, video, loading, link }, } = createDocBuilder({ tiptapEditor, names: { loading: { markType: Loading.name }, image: { nodeType: Image.name }, link: { nodeType: Link.name }, + audio: { nodeType: Audio.name }, + video: { nodeType: Video.name }, }, })); @@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); }); - describe('when the file has image mime type', () => { - const base64EncodedFile = 'data:image/png;base64,Zm9v'; + describe.each` + nodeType | mimeType | html | file | mediaType + ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)} + ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)} + ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)} + `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => { + const base64EncodedFile = `data:${mimeType};base64,Zm9v`; beforeEach(() => { - renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); + renderMarkdown.mockResolvedValue(html); }); describe('when uploading succeeds', () => { const successResponse = { link: { - markdown: '![test-file](test-file.png)', + markdown: `![test-file](${file.name})`, }, }; @@ -121,21 +155,21 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts an image with src set to the encoded image file and uploading true', async () => { - const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); + it('inserts a media content with src set to the encoded content and uploading true', async () => { + const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile }))); await expectDocumentAfterTransaction({ number: 1, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('updates the inserted image with canonicalSrc when upload is successful', async () => { + it('updates the inserted content with canonicalSrc when upload is successful', async () => { const expectedDoc = doc( p( - image({ - canonicalSrc: 'test-file.png', + mediaType({ + canonicalSrc: file.name, src: base64EncodedFile, alt: 'test-file', uploading: false, @@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); }); @@ -162,17 +196,19 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('emits an alert event that includes an error message', (done) => { - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + it('emits an alert event that includes an error message', () => { + tiptapEditor.commands.uploadAttachment({ file }); - eventHub.$on('alert', ({ message, variant }) => { - expect(variant).toBe(VARIANT_DANGER); - expect(message).toBe('An error occurred while uploading the image. Please try again.'); - done(); + return new Promise((resolve) => { + eventHub.$on('alert', ({ message, variant }) => { + expect(variant).toBe(VARIANT_DANGER); + expect(message).toBe('An error occurred while uploading the file. Please try again.'); + resolve(); + }); }); }); }); @@ -243,13 +279,12 @@ describe('content_editor/extensions/attachment', () => { }); }); - it('emits an alert event that includes an error message', (done) => { + it('emits an alert event that includes an error message', () => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); eventHub.$on('alert', ({ message, variant }) => { expect(variant).toBe(VARIANT_DANGER); expect(message).toBe('An error occurred while uploading the file. Please try again.'); - done(); }); }); }); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 05fa0f79ef0..02e5b1dc271 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,5 +1,5 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"> <code> @@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language describe('content_editor/extensions/code_block_highlight', () => { let parsedCodeBlockHtmlFixture; let tiptapEditor; + let doc; + let codeBlock; + let languageLoader; const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); beforeEach(() => { - tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); - parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); + languageLoader = { loadLanguages: jest.fn() }; + tiptapEditor = createTestEditor({ + extensions: [CodeBlockHighlight.configure({ languageLoader })], + }); - tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + ({ + builders: { doc, codeBlock }, + } = createDocBuilder({ + tiptapEditor, + names: { + codeBlock: { nodeType: CodeBlockHighlight.name }, + }, + })); }); - it('extracts language and params attributes from Markdown API output', () => { - const language = preElement().getAttribute('lang'); + describe('when parsing HTML', () => { + beforeEach(() => { + parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML); - expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ - language, + tiptapEditor.commands.setContent(CODE_BLOCK_HTML); + }); + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); }); - }); - it('adds code, highlight, and js-syntax-highlight to code block element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + it('adds content-editor-code-block class to the pre element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); - expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + }); }); - it('adds content-editor-code-block class to the pre element', () => { - const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + describe.each` + inputRule + ${'```'} + ${'~~~'} + `('when typing $inputRule input rule', ({ inputRule }) => { + const language = 'javascript'; + + beforeEach(() => { + triggerNodeInputRule({ + tiptapEditor, + inputRuleText: `${inputRule}${language} `, + }); + }); + + it('creates a new code block and loads related language', () => { + const expectedDoc = doc(codeBlock({ language })); - expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block'); + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + + it('loads language when language loader is available', () => { + expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]); + }); }); }); diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js index a8cbad6ef81..4f80c2cb81a 100644 --- a/spec/frontend/content_editor/extensions/frontmatter_spec.js +++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js @@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => { }); it('does not insert a frontmatter block when executing code block input rule', () => { - const expectedDoc = doc(codeBlock('')); + const expectedDoc = doc(codeBlock({ language: 'plaintext' }, '')); const inputRuleText = '``` '; triggerNodeInputRule({ tiptapEditor, inputRuleText }); diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js new file mode 100644 index 00000000000..905c1685b94 --- /dev/null +++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js @@ -0,0 +1,120 @@ +import codeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader'; +import waitForPromises from 'helpers/wait_for_promises'; +import { backtickInputRegex } from '~/content_editor/extensions/code_block_highlight'; + +describe('content_editor/services/code_block_language_loader', () => { + let languageLoader; + let lowlight; + + beforeEach(() => { + lowlight = { + languages: [], + registerLanguage: jest + .fn() + .mockImplementation((language) => lowlight.languages.push(language)), + registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)), + }; + languageLoader = codeBlockLanguageBlocker; + languageLoader.lowlight = lowlight; + }); + + describe('findLanguageBySyntax', () => { + it.each` + syntax | language + ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }} + ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }} + ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }} + `('returns a language by syntax and its variants', ({ syntax, language }) => { + expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language); + }); + + it('returns Custom (syntax) if the language does not exist', () => { + expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({ + syntax: 'foobar', + label: 'Custom (foobar)', + }); + }); + + it('returns plaintext if no syntax is passed', () => { + expect(languageLoader.findLanguageBySyntax('')).toMatchObject({ + syntax: 'plaintext', + label: 'Plain text', + }); + }); + }); + + describe('filterLanguages', () => { + it('filters languages by the given search term', () => { + expect(languageLoader.filterLanguages('ts')).toEqual([ + { label: 'Device Tree', syntax: 'dts' }, + { label: 'Kotlin', syntax: 'kotlin', variants: 'kt, kts' }, + { label: 'TypeScript', syntax: 'typescript', variants: 'ts, tsx' }, + ]); + }); + }); + + describe('loadLanguages', () => { + it('loads highlight.js language packages identified by a list of languages', async () => { + const languages = ['javascript', 'ruby']; + + await languageLoader.loadLanguages(languages); + + languages.forEach((language) => { + expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function)); + }); + }); + + describe('when language is already registered', () => { + it('does not load the language again', async () => { + const languages = ['javascript']; + + await languageLoader.loadLanguages(languages); + await languageLoader.loadLanguages(languages); + + expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('loadLanguagesFromDOM', () => { + it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => { + const parser = new DOMParser(); + const { body } = parser.parseFromString( + ` + <pre lang="javascript"></pre> + <pre lang="ruby"></pre> + `, + 'text/html', + ); + + await languageLoader.loadLanguagesFromDOM(body); + + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function)); + }); + }); + + describe('loadLanguageFromInputRule', () => { + it('loads highlight.js language packages identified from the input rule', async () => { + const match = new RegExp(backtickInputRegex).exec('```js '); + const attrs = languageLoader.loadLanguageFromInputRule(match); + + await waitForPromises(); + + expect(attrs).toEqual({ language: 'javascript' }); + expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function)); + }); + }); + + describe('isLanguageLoaded', () => { + it('returns true when a language is registered', async () => { + const language = 'javascript'; + + expect(languageLoader.isLanguageLoaded(language)).toBe(false); + + await languageLoader.loadLanguages([language]); + + expect(languageLoader.isLanguageLoaded(language)).toBe(true); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index 3bc72b13302..5b7a27b501d 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; let deserializer; + let languageLoader; let eventHub; let doc; let p; @@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => { serializer = { deserialize: jest.fn() }; deserializer = { deserialize: jest.fn() }; + languageLoader = { loadLanguagesFromDOM: jest.fn() }; eventHub = eventHubFactory(); - contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub }); + contentEditor = new ContentEditor({ + tiptapEditor, + serializer, + deserializer, + eventHub, + languageLoader, + }); }); describe('.dispose', () => { @@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => { describe('when setSerializedContent succeeds', () => { let document; + const dom = {}; + const testMarkdown = '**bold text**'; beforeEach(() => { document = doc(p('document')); - deserializer.deserialize.mockResolvedValueOnce({ document }); + deserializer.deserialize.mockResolvedValueOnce({ document, dom }); }); it('emits loadingContent and loadingSuccess event in the eventHub', () => { @@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => { expect(loadingContentEmitted).toBe(true); }); - contentEditor.setSerializedContent('**bold text**'); + contentEditor.setSerializedContent(testMarkdown); }); it('sets the deserialized document in the tiptap editor object', async () => { - await contentEditor.setSerializedContent('**bold text**'); + await contentEditor.setSerializedContent(testMarkdown); expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON()); }); + + it('passes deserialized DOM document to language loader', async () => { + await contentEditor.setSerializedContent(testMarkdown); + + expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom); + }); }); describe('when setSerializedContent fails', () => { |