diff options
Diffstat (limited to 'spec/frontend/vue_shared/components/source_viewer')
4 files changed, 201 insertions, 83 deletions
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js new file mode 100644 index 00000000000..eb2eec92534 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -0,0 +1,69 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +const DEFAULT_PROPS = { + number: 2, + content: '// Line content', + language: 'javascript', +}; + +describe('Chunk Line component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findLink = () => wrapper.findComponent(GlLink); + const findContent = () => wrapper.findByTestId('content'); + const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('rendering', () => { + it('wraps BiDi characters', () => { + const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`; + createComponent({ content }); + const wrappedBidiChars = findWrappedBidiChars(); + + expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length); + + wrappedBidiChars.wrappers.forEach((_, i) => { + expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]); + expect(wrappedBidiChars.at(i).attributes()).toMatchObject({ + class: BIDI_CHARS_CLASS_LIST, + title: BIDI_CHAR_TOOLTIP, + }); + }); + }); + + it('renders a line number', () => { + expect(findLink().attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.number}`, + to: `#L${DEFAULT_PROPS.number}`, + id: `L${DEFAULT_PROPS.number}`, + }); + + expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString()); + }); + + it('renders content', () => { + expect(findContent().attributes()).toMatchObject({ + id: `LC${DEFAULT_PROPS.number}`, + lang: DEFAULT_PROPS.language, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js new file mode 100644 index 00000000000..42c4f2eacb8 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -0,0 +1,82 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', +}; + +describe('Chunk component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('emits an appear event when intersection-observer appears', () => { + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); + + it('does not emit an appear event is isHighlighted is true', () => { + createComponent({ isHighlighted: true }); + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual(undefined); + }); + }); + + describe('rendering', () => { + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); + }); + + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); + + expect(findLineNumbers().at(0).attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`, + href: `#L${DEFAULT_PROPS.startingFrom + 1}`, + id: `L${DEFAULT_PROPS.startingFrom + 1}`, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); + + expect(findChunkLines().length).toBe(splitContent.length); + + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index ab579945e22..6a9ea75127d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -1,24 +1,38 @@ import hljs from 'highlight.js/lib/core'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); Vue.use(VueRouter); const router = new VueRouter(); +const generateContent = (content, totalLines = 1) => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}\n`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); + describe('Source Viewer component', () => { let wrapper; const language = 'docker'; const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const content = `// Some source code`; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content }; + const chunk1 = generateContent('// Some source code 1', 70); + const chunk2 = generateContent('// Some source code 2', 70); + const content = chunk1 + chunk2; + const path = 'some/path.js'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path }; const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`; const createComponent = async (blob = {}) => { @@ -29,15 +43,13 @@ describe('Source Viewer component', () => { await waitForPromises(); }; - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findLineNumbers = () => wrapper.findComponent(LineNumbers); - const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); - const findFirstLine = () => wrapper.find('#LC1'); + const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(sourceViewerUtils, 'wrapLines'); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); return createComponent(); }); @@ -45,6 +57,8 @@ describe('Source Viewer component', () => { afterEach(() => wrapper.destroy()); describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + it('registers the language definition', async () => { const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); @@ -54,72 +68,51 @@ describe('Source Viewer component', () => { ); }); - it('highlights the content', () => { - expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage }); + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); }); describe('auto-detects if a language cannot be loaded', () => { beforeEach(() => createComponent({ language: 'some_unknown_language' })); it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(content); + expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); }); }); }); describe('rendering', () => { - it('renders a loading icon if no highlighted content is available yet', async () => { - hljs.highlight.mockImplementation(() => ({ value: null })); - await createComponent(); - - expect(findLoadingIcon().exists()).toBe(true); - }); + it('renders the first chunk', async () => { + const firstChunk = findChunks().at(0); - it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => { - expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage); - }); - - it('renders Line Numbers', () => { - expect(findLineNumbers().props('lines')).toBe(1); - }); + expect(firstChunk.props('content')).toContain(chunk1); - it('renders the highlighted content', () => { - expect(findHighlightedContent().exists()).toBe(true); + expect(firstChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 0, + }); }); - }); - describe('selecting a line', () => { - let firstLine; - let firstLineElement; + it('renders the second chunk', async () => { + const secondChunk = findChunks().at(1); - beforeEach(() => { - firstLine = findFirstLine(); - firstLineElement = firstLine.element; + expect(secondChunk.props('content')).toContain(chunk2.trim()); - jest.spyOn(firstLineElement, 'scrollIntoView'); - jest.spyOn(firstLineElement.classList, 'add'); - jest.spyOn(firstLineElement.classList, 'remove'); - }); - - it('adds the highlight (hll) class', async () => { - wrapper.vm.$router.push('#LC1'); - await nextTick(); - - expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll'); + expect(secondChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 70, + }); }); + }); - it('removes the highlight (hll) class from a previously highlighted line', async () => { - wrapper.vm.$router.push('#LC2'); - await nextTick(); - - expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll'); - }); + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toBeCalledWith('showBlobInteractionZones', path); + }); - it('scrolls the line into view', () => { - expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', async () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js deleted file mode 100644 index 0631e7efd54..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { wrapLines } from '~/vue_shared/components/source_viewer/utils'; - -describe('Wrap lines', () => { - it.each` - content | language | output - ${'line 1'} | ${'javascript'} | ${'<span id="LC1" lang="javascript" class="line">line 1</span>'} - ${'line 1\nline 2'} | ${'html'} | ${`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`} - ${'<span class="hljs-code">line 1\nline 2</span>'} | ${'html'} | ${`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`} - ${'<span class="hljs-code">```bash'} | ${'bash'} | ${'<span id="LC1" lang="bash" class="hljs-code">```bash'} - ${'<span class="hljs-code">```bash'} | ${'valid-language1'} | ${'<span id="LC1" lang="valid-language1" class="hljs-code">```bash'} - ${'<span class="hljs-code">```bash'} | ${'valid_language2'} | ${'<span id="LC1" lang="valid_language2" class="hljs-code">```bash'} - `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => { - expect(wrapLines(content, language)).toBe(output); - }); - - it.each` - language - ${'invalidLanguage>'} - ${'"invalidLanguage"'} - ${'<invalidLanguage'} - `('returns lines safely without XSS language is not valid', ({ language }) => { - expect(wrapLines('<span class="hljs-code">```bash', language)).toBe( - '<span id="LC1" lang="" class="hljs-code">```bash', - ); - }); -}); |