summaryrefslogtreecommitdiff
path: root/spec/frontend/content_editor
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/content_editor')
-rw-r--r--spec/frontend/content_editor/components/code_block_bubble_menu_spec.js142
-rw-r--r--spec/frontend/content_editor/components/formatting_bubble_menu_spec.js2
-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.js79
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js74
-rw-r--r--spec/frontend/content_editor/extensions/frontmatter_spec.js2
-rw-r--r--spec/frontend/content_editor/services/code_block_language_loader_spec.js120
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js24
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', () => {