import MockAdapter from 'axios-mock-adapter'; import { Range, Position, editor as monacoEditor } from 'monaco-editor'; import waitForPromises from 'helpers/wait_for_promises'; import { EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY, } from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import SourceEditor from '~/editor/source_editor'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import syntaxHighlight from '~/syntax_highlight'; jest.mock('~/syntax_highlight'); jest.mock('~/flash'); describe('Markdown Extension for Source Editor', () => { let editor; let instance; let editorEl; let panelSpy; let mockAxios; const previewMarkdownPath = '/gitlab/fooGroup/barProj/preview_markdown'; const firstLine = 'This is a'; const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; const plaintextPath = 'foo.txt'; const markdownPath = 'foo.md'; const responseData = '
FooBar
'; const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); instance.setSelection(selection); }; const selectSecondString = () => setSelection(2, 1, 2, secondLine.length + 1); // select the whole second line const selectSecondAndThirdLines = () => setSelection(2, 1, 3, thirdLine.length + 1); // select second and third lines const selectionToString = () => instance.getSelection().toString(); const positionToString = () => instance.getPosition().toString(); const togglePreview = async () => { instance.togglePreview(); await waitForPromises(); }; beforeEach(() => { mockAxios = new MockAdapter(axios); setFixtures('
'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); instance = editor.createInstance({ el: editorEl, blobPath: markdownPath, blobContent: text, }); editor.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); }); afterEach(() => { instance.dispose(); editorEl.remove(); mockAxios.restore(); }); it('sets up the instance', () => { expect(instance.preview).toEqual({ el: undefined, action: expect.any(Object), shown: false, modelChangeListener: undefined, }); expect(instance.previewMarkdownPath).toBe(previewMarkdownPath); }); describe('model language changes listener', () => { let cleanupSpy; let actionSpy; beforeEach(async () => { cleanupSpy = jest.spyOn(instance, 'cleanup'); actionSpy = jest.spyOn(instance, 'setupPreviewAction'); await togglePreview(); }); it('cleans up when switching away from markdown', () => { expect(instance.cleanup).not.toHaveBeenCalled(); expect(instance.setupPreviewAction).not.toHaveBeenCalled(); instance.updateModelLanguage(plaintextPath); expect(cleanupSpy).toHaveBeenCalled(); expect(actionSpy).not.toHaveBeenCalled(); }); it.each` oldLanguage | newLanguage | setupCalledTimes ${'plaintext'} | ${'markdown'} | ${1} ${'markdown'} | ${'markdown'} | ${0} ${'markdown'} | ${'plaintext'} | ${0} ${'markdown'} | ${undefined} | ${0} ${undefined} | ${'markdown'} | ${1} `( 'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage', ({ oldLanguage, newLanguage, setupCalledTimes } = {}) => { expect(actionSpy).not.toHaveBeenCalled(); instance.updateModelLanguage(oldLanguage); instance.updateModelLanguage(newLanguage); expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); }, ); }); describe('model change listener', () => { let cleanupSpy; let actionSpy; beforeEach(() => { cleanupSpy = jest.spyOn(instance, 'cleanup'); actionSpy = jest.spyOn(instance, 'setupPreviewAction'); instance.togglePreview(); }); afterEach(() => { jest.clearAllMocks(); }); it('does not do anything if there is no model', () => { instance.setModel(null); expect(cleanupSpy).not.toHaveBeenCalled(); expect(actionSpy).not.toHaveBeenCalled(); }); it('cleans up the preview when the model changes', () => { instance.setModel(monacoEditor.createModel('foo')); expect(cleanupSpy).toHaveBeenCalled(); }); it.each` language | setupCalledTimes ${'markdown'} | ${1} ${'plaintext'} | ${0} ${undefined} | ${0} `( 'correctly handles actions when the new model is $language', ({ language, setupCalledTimes } = {}) => { instance.setModel(monacoEditor.createModel('foo', language)); expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes); }, ); }); describe('cleanup', () => { beforeEach(async () => { mockAxios.onPost().reply(200, { body: responseData }); await togglePreview(); }); it('disposes the modelChange listener and does not fetch preview on content changes', () => { expect(instance.preview.modelChangeListener).toBeDefined(); jest.spyOn(instance, 'fetchPreview'); instance.cleanup(); instance.setValue('Foo Bar'); jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY); expect(instance.fetchPreview).not.toHaveBeenCalled(); }); it('removes the contextual menu action', () => { expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); instance.cleanup(); expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBe(null); }); it('toggles the `shown` flag', () => { expect(instance.preview.shown).toBe(true); instance.cleanup(); expect(instance.preview.shown).toBe(false); }); it('toggles the panel only if the preview is visible', () => { const { el: previewEl } = instance.preview; const parentEl = previewEl.parentElement; expect(previewEl).toBeVisible(); expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe(true); instance.cleanup(); expect(previewEl).toBeHidden(); expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( false, ); instance.cleanup(); expect(previewEl).toBeHidden(); expect(parentEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( false, ); }); it('toggles the layout only if the preview is visible', () => { const { width } = instance.getLayoutInfo(); expect(instance.preview.shown).toBe(true); instance.cleanup(); const { width: newWidth } = instance.getLayoutInfo(); expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); instance.cleanup(); expect(newWidth === width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH).toBe(true); }); }); describe('fetchPreview', () => { const fetchPreview = async () => { instance.fetchPreview(); await waitForPromises(); }; let previewMarkdownSpy; beforeEach(() => { previewMarkdownSpy = jest.fn().mockImplementation(() => [200, { body: responseData }]); mockAxios.onPost(previewMarkdownPath).replyOnce((req) => previewMarkdownSpy(req)); }); it('correctly fetches preview based on previewMarkdownPath', async () => { await fetchPreview(); expect(previewMarkdownSpy).toHaveBeenCalledWith( expect.objectContaining({ data: JSON.stringify({ text }) }), ); }); it('puts the fetched content into the preview DOM element', async () => { instance.preview.el = editorEl.parentElement; await fetchPreview(); expect(instance.preview.el.innerHTML).toEqual(responseData); }); it('applies syntax highlighting to the preview content', async () => { instance.preview.el = editorEl.parentElement; await fetchPreview(); expect(syntaxHighlight).toHaveBeenCalled(); }); it('catches the errors when fetching the preview', async () => { mockAxios.onPost().reply(500); await fetchPreview(); expect(createFlash).toHaveBeenCalled(); }); }); describe('setupPreviewAction', () => { it('adds the contextual menu action', () => { expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); }); it('does not set up action if one already exists', () => { jest.spyOn(instance, 'addAction').mockImplementation(); instance.setupPreviewAction(); expect(instance.addAction).not.toHaveBeenCalled(); }); it('toggles preview when the action is triggered', () => { jest.spyOn(instance, 'togglePreview').mockImplementation(); expect(instance.togglePreview).not.toHaveBeenCalled(); const action = instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID); action.run(); expect(instance.togglePreview).toHaveBeenCalled(); }); }); describe('togglePreview', () => { beforeEach(() => { mockAxios.onPost().reply(200, { body: responseData }); }); it('toggles preview flag on instance', () => { expect(instance.preview.shown).toBe(false); instance.togglePreview(); expect(instance.preview.shown).toBe(true); instance.togglePreview(); expect(instance.preview.shown).toBe(false); }); describe('panel DOM element set up', () => { it('sets up an element to contain the preview and stores it on instance', () => { expect(instance.preview.el).toBeUndefined(); instance.togglePreview(); expect(instance.preview.el).toBeDefined(); expect(instance.preview.el.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS)).toBe( true, ); }); it('re-uses existing preview DOM element on repeated calls', () => { instance.togglePreview(); const origPreviewEl = instance.preview.el; instance.togglePreview(); expect(instance.preview.el).toBe(origPreviewEl); }); it('hides the preview DOM element by default', () => { panelSpy.mockImplementation(); instance.togglePreview(); expect(instance.preview.el.style.display).toBe('none'); }); }); describe('preview layout setup', () => { it('sets correct preview layout', () => { jest.spyOn(instance, 'layout'); const { width, height } = instance.getLayoutInfo(); instance.togglePreview(); expect(instance.layout).toHaveBeenCalledWith({ width: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, height, }); }); }); describe('preview panel', () => { it('toggles preview CSS class on the editor', () => { expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( false, ); instance.togglePreview(); expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( true, ); instance.togglePreview(); expect(editorEl.classList.contains(EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS)).toBe( false, ); }); it('toggles visibility of the preview DOM element', async () => { await togglePreview(); expect(instance.preview.el.style.display).toBe('block'); await togglePreview(); expect(instance.preview.el.style.display).toBe('none'); }); describe('hidden preview DOM element', () => { it('listens to model changes and re-fetches preview', async () => { expect(mockAxios.history.post).toHaveLength(0); await togglePreview(); expect(mockAxios.history.post).toHaveLength(1); instance.setValue('New Value'); await waitForPromises(); expect(mockAxios.history.post).toHaveLength(2); }); it('stores disposable listener for model changes', async () => { expect(instance.preview.modelChangeListener).toBeUndefined(); await togglePreview(); expect(instance.preview.modelChangeListener).toBeDefined(); }); }); describe('already visible preview', () => { beforeEach(async () => { await togglePreview(); mockAxios.resetHistory(); }); it('does not re-fetch the preview', () => { instance.togglePreview(); expect(mockAxios.history.post).toHaveLength(0); }); it('disposes the model change event listener', () => { const disposeSpy = jest.fn(); instance.preview.modelChangeListener = { dispose: disposeSpy, }; instance.togglePreview(); expect(disposeSpy).toHaveBeenCalled(); }); }); }); }); describe('getSelectedText', () => { it('does not fail if there is no selection and returns the empty string', () => { jest.spyOn(instance, 'getSelection'); const resText = instance.getSelectedText(); expect(instance.getSelection).toHaveBeenCalled(); expect(resText).toBe(''); }); it.each` description | selection | expectedString ${'same-line'} | ${[1, 1, 1, firstLine.length + 1]} | ${firstLine} ${'two-lines'} | ${[1, 1, 2, secondLine.length + 1]} | ${`${firstLine}\n${secondLine}`} ${'multi-lines'} | ${[1, 1, 3, thirdLine.length + 1]} | ${text} `('correctly returns selected text for $description', ({ selection, expectedString }) => { setSelection(...selection); const resText = instance.getSelectedText(); expect(resText).toBe(expectedString); }); it('accepts selection object that serves as a source instead of current selection', () => { selectSecondString(); const firstLineSelection = new Range(1, 1, 1, firstLine.length + 1); const resText = instance.getSelectedText(firstLineSelection); expect(resText).toBe(firstLine); }); }); describe('replaceSelectedText', () => { const expectedStr = 'foo'; it('replaces selected text with the supplied one', () => { selectSecondString(); instance.replaceSelectedText(expectedStr); expect(instance.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`); }); it('prepends the supplied text if no text is selected', () => { instance.replaceSelectedText(expectedStr); expect(instance.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`); }); it('replaces selection with empty string if no text is supplied', () => { selectSecondString(); instance.replaceSelectedText(); expect(instance.getValue()).toBe(`${firstLine}\n\n${thirdLine}`); }); it('puts cursor at the end of the new string and collapses selection by default', () => { selectSecondString(); instance.replaceSelectedText(expectedStr); expect(positionToString()).toBe(`(2,${expectedStr.length + 1})`); expect(selectionToString()).toBe( `[2,${expectedStr.length + 1} -> 2,${expectedStr.length + 1}]`, ); }); it('puts cursor at the end of the new string and keeps selection if "select" is supplied', () => { const select = 'url'; const complexReplacementString = `[${secondLine}](${select})`; selectSecondString(); instance.replaceSelectedText(complexReplacementString, select); expect(positionToString()).toBe(`(2,${complexReplacementString.length + 1})`); expect(selectionToString()).toBe(`[2,1 -> 2,${complexReplacementString.length + 1}]`); }); }); describe('moveCursor', () => { const setPosition = (endCol) => { const currentPos = new Position(2, endCol); instance.setPosition(currentPos); }; it.each` direction | condition | startColumn | shift | endPosition ${'left'} | ${'negative'} | ${secondLine.length + 1} | ${-1} | ${`(2,${secondLine.length})`} ${'left'} | ${'negative'} | ${secondLine.length} | ${secondLine.length * -1} | ${'(2,1)'} ${'right'} | ${'positive'} | ${1} | ${1} | ${'(2,2)'} ${'right'} | ${'positive'} | ${2} | ${secondLine.length} | ${`(2,${secondLine.length + 1})`} ${'up'} | ${'positive'} | ${1} | ${[0, -1]} | ${'(1,1)'} ${'top of file'} | ${'positive'} | ${1} | ${[0, -100]} | ${'(1,1)'} ${'down'} | ${'negative'} | ${1} | ${[0, 1]} | ${'(3,1)'} ${'end of file'} | ${'negative'} | ${1} | ${[0, 100]} | ${`(3,${thirdLine.length + 1})`} ${'end of line'} | ${'too large'} | ${1} | ${secondLine.length + 100} | ${`(2,${secondLine.length + 1})`} ${'start of line'} | ${'too low'} | ${1} | ${-100} | ${'(2,1)'} `( 'moves cursor to the $direction if $condition supplied', ({ startColumn, shift, endPosition }) => { setPosition(startColumn); if (Array.isArray(shift)) { instance.moveCursor(...shift); } else { instance.moveCursor(shift); } expect(positionToString()).toBe(endPosition); }, ); }); describe('selectWithinSelection', () => { it('scopes down current selection to supplied text', () => { const selectedText = `${secondLine}\n${thirdLine}`; const toSelect = 'string'; selectSecondAndThirdLines(); expect(selectionToString()).toBe(`[2,1 -> 3,${thirdLine.length + 1}]`); instance.selectWithinSelection(toSelect, selectedText); expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); }); it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => { jest.spyOn(instance, 'getSelectedText'); const toSelect = 'string'; selectSecondAndThirdLines(); instance.selectWithinSelection(toSelect); expect(instance.getSelectedText).toHaveBeenCalled(); expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); }); it('does nothing if no `toSelect` is supplied', () => { selectSecondAndThirdLines(); const expectedPos = `(3,${thirdLine.length + 1})`; const expectedSelection = `[2,1 -> 3,${thirdLine.length + 1}]`; expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); instance.selectWithinSelection(); expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); }); it('does nothing if no selection is set in the editor', () => { const expectedPos = '(1,1)'; const expectedSelection = '[1,1 -> 1,1]'; const toSelect = 'string'; expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); instance.selectWithinSelection(toSelect); expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); instance.selectWithinSelection(); expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); }); }); });