diff options
Diffstat (limited to 'spec/frontend/editor')
-rw-r--r-- | spec/frontend/editor/source_editor_extension_base_spec.js | 8 | ||||
-rw-r--r-- | spec/frontend/editor/source_editor_markdown_ext_spec.js | 385 | ||||
-rw-r--r-- | spec/frontend/editor/utils_spec.js | 85 |
3 files changed, 468 insertions, 10 deletions
diff --git a/spec/frontend/editor/source_editor_extension_base_spec.js b/spec/frontend/editor/source_editor_extension_base_spec.js index 352db9d0d51..2c06ae03892 100644 --- a/spec/frontend/editor/source_editor_extension_base_spec.js +++ b/spec/frontend/editor/source_editor_extension_base_spec.js @@ -1,5 +1,6 @@ import { Range } from 'monaco-editor'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE, @@ -152,12 +153,7 @@ describe('The basis for an Source Editor extension', () => { useFakeRequestAnimationFrame(); beforeEach(() => { - delete window.location; - window.location = new URL(`https://localhost`); - }); - - afterEach(() => { - window.location.hash = ''; + setWindowLocation('https://localhost'); }); it.each` diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 943e21250b4..48ccc10e486 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,16 +1,36 @@ -import { Range, Position } from 'monaco-editor'; +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 projectPath = 'fooGroup/barProj'; const firstLine = 'This is a'; const secondLine = 'multiline'; const thirdLine = 'string with some **markup**'; const text = `${firstLine}\n${secondLine}\n${thirdLine}`; - const filePath = 'foo.md'; + const plaintextPath = 'foo.txt'; + const markdownPath = 'foo.md'; + const responseData = '<div>FooBar</div>'; const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); @@ -22,21 +42,378 @@ describe('Markdown Extension for Source Editor', () => { const selectionToString = () => instance.getSelection().toString(); const positionToString = () => instance.getPosition().toString(); + const togglePreview = async () => { + instance.togglePreview(); + await waitForPromises(); + }; + beforeEach(() => { + mockAxios = new MockAdapter(axios); setFixtures('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); editor = new SourceEditor(); instance = editor.createInstance({ el: editorEl, - blobPath: filePath, + blobPath: markdownPath, blobContent: text, }); - editor.use(new EditorMarkdownExtension()); + editor.use(new EditorMarkdownExtension({ instance, projectPath })); + 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.projectPath).toBe(projectPath); + }); + + 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 group = 'foo'; + const project = 'bar'; + const setData = (path, g, p) => { + instance.projectPath = path; + document.body.setAttribute('data-group', g); + document.body.setAttribute('data-project', p); + }; + const fetchPreview = async () => { + instance.fetchPreview(); + await waitForPromises(); + }; + + beforeEach(() => { + mockAxios.onPost().reply(200, { body: responseData }); + }); + + it('correctly fetches preview based on projectPath', async () => { + setData(projectPath, group, project); + await fetchPreview(); + expect(mockAxios.history.post[0].url).toBe(`/${projectPath}/preview_markdown`); + expect(mockAxios.history.post[0].data).toEqual(JSON.stringify({ text })); + }); + + it('correctly fetches preview based on group and project data attributes', async () => { + setData(undefined, group, project); + await fetchPreview(); + expect(mockAxios.history.post[0].url).toBe(`/${group}/${project}/preview_markdown`); + expect(mockAxios.history.post[0].data).toEqual(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', () => { diff --git a/spec/frontend/editor/utils_spec.js b/spec/frontend/editor/utils_spec.js new file mode 100644 index 00000000000..97d3e9e081d --- /dev/null +++ b/spec/frontend/editor/utils_spec.js @@ -0,0 +1,85 @@ +import { editor as monacoEditor } from 'monaco-editor'; +import * as utils from '~/editor/utils'; +import { DEFAULT_THEME } from '~/ide/lib/themes'; + +describe('Source Editor utils', () => { + let el; + + const stubUserColorScheme = (value) => { + if (window.gon == null) { + window.gon = {}; + } + window.gon.user_color_scheme = value; + }; + + describe('clearDomElement', () => { + beforeEach(() => { + setFixtures('<div id="foo"><div id="bar">Foo</div></div>'); + el = document.getElementById('foo'); + }); + + it('removes all child nodes from an element', () => { + expect(el.children.length).toBe(1); + utils.clearDomElement(el); + expect(el.children.length).toBe(0); + }); + }); + + describe('setupEditorTheme', () => { + beforeEach(() => { + jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(); + jest.spyOn(monacoEditor, 'setTheme').mockImplementation(); + }); + + it.each` + themeName | expectedThemeName + ${'solarized-light'} | ${'solarized-light'} + ${DEFAULT_THEME} | ${DEFAULT_THEME} + ${'non-existent'} | ${DEFAULT_THEME} + `( + 'sets the $expectedThemeName theme when $themeName is set in the user preference', + ({ themeName, expectedThemeName }) => { + stubUserColorScheme(themeName); + utils.setupEditorTheme(); + + expect(monacoEditor.setTheme).toHaveBeenCalledWith(expectedThemeName); + }, + ); + }); + + describe('getBlobLanguage', () => { + it.each` + path | expectedLanguage + ${'foo.js'} | ${'javascript'} + ${'foo.js.rb'} | ${'ruby'} + ${'foo.bar'} | ${'plaintext'} + ${undefined} | ${'plaintext'} + `( + 'sets the $expectedThemeName theme when $themeName is set in the user preference', + ({ path, expectedLanguage }) => { + const language = utils.getBlobLanguage(path); + + expect(language).toEqual(expectedLanguage); + }, + ); + }); + + describe('setupCodeSnipet', () => { + beforeEach(() => { + jest.spyOn(monacoEditor, 'colorizeElement').mockImplementation(); + jest.spyOn(monacoEditor, 'setTheme').mockImplementation(); + setFixtures('<pre id="foo"></pre>'); + el = document.getElementById('foo'); + }); + + it('colorizes the element and applies the preference theme', () => { + expect(monacoEditor.colorizeElement).not.toHaveBeenCalled(); + expect(monacoEditor.setTheme).not.toHaveBeenCalled(); + + utils.setupCodeSnippet(el); + + expect(monacoEditor.colorizeElement).toHaveBeenCalledWith(el); + expect(monacoEditor.setTheme).toHaveBeenCalled(); + }); + }); +}); |