diff options
Diffstat (limited to 'spec/frontend/vue_shared')
28 files changed, 813 insertions, 485 deletions
diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index d309432bc63..3bc191d988f 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -30,7 +30,7 @@ describe('AlertDetails', () => { const projectPath = 'root/alerts'; const projectIssuesPath = 'root/alerts/-/issues'; const projectId = '1'; - const $router = { replace: jest.fn() }; + const $router = { push: jest.fn() }; function mountComponent({ data, @@ -352,7 +352,7 @@ describe('AlertDetails', () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ currentTabIndex: index }); - expect($router.replace).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); + expect($router.push).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); }); }); }); diff --git a/spec/frontend/vue_shared/alert_details/router_spec.js b/spec/frontend/vue_shared/alert_details/router_spec.js new file mode 100644 index 00000000000..e3efc104862 --- /dev/null +++ b/spec/frontend/vue_shared/alert_details/router_spec.js @@ -0,0 +1,35 @@ +import createRouter from '~/vue_shared/alert_details/router'; +import setWindowLocation from 'helpers/set_window_location_helper'; + +const BASE_PATH = '/-/alert_management/1/details'; +const EMPTY_HASH = ''; +const NOOP = () => {}; + +describe('AlertDetails router', () => { + const originalLocation = window.location.href; + let router; + + beforeEach(() => { + setWindowLocation(originalLocation); + router = createRouter(BASE_PATH); + }); + + describe('redirects hash route mode URLs to history route mode', () => { + it.each` + hashPath | historyPath + ${'/#/overview'} | ${'/overview'} + ${'#/overview'} | ${'/overview'} + ${'/#/'} | ${'/'} + ${'#/'} | ${'/'} + ${'/#'} | ${'/'} + ${'#'} | ${'/'} + ${'/'} | ${'/'} + ${'/overview'} | ${'/overview'} + `('should redirect "$hashPath" to "$historyPath"', ({ hashPath, historyPath }) => { + router.push(hashPath, NOOP); + + expect(window.location.hash).toBe(EMPTY_HASH); + expect(window.location.pathname).toBe(BASE_PATH + historyPath); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index f5a545891d5..c3a71d7fda3 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -106,7 +106,7 @@ describe('File row component', () => { level: 2, }); - expect(wrapper.find('.file-row-name').element.style.marginLeft).toBe('32px'); + expect(wrapper.find('.file-row-name').element.style.marginLeft).toBe('16px'); }); it('renders header for file', () => { diff --git a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js b/spec/frontend/vue_shared/components/gitlab_version_check_spec.js deleted file mode 100644 index 38f28837cc1..00000000000 --- a/spec/frontend/vue_shared/components/gitlab_version_check_spec.js +++ /dev/null @@ -1,135 +0,0 @@ -import { GlBadge } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { mockTracking } from 'helpers/tracking_helper'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import axios from '~/lib/utils/axios_utils'; -import GitlabVersionCheck from '~/vue_shared/components/gitlab_version_check.vue'; - -describe('GitlabVersionCheck', () => { - let wrapper; - let mock; - - const UPGRADE_DOCS_URL = helpPagePath('update/index'); - - const defaultResponse = { - code: 200, - res: { severity: 'success' }, - }; - - const createComponent = (mockResponse) => { - const response = { - ...defaultResponse, - ...mockResponse, - }; - - mock = new MockAdapter(axios); - mock.onGet().replyOnce(response.code, response.res); - - wrapper = shallowMountExtended(GitlabVersionCheck); - }; - - const dummyGon = { - relative_url_root: '/', - }; - - let originalGon; - - afterEach(() => { - wrapper.destroy(); - mock.restore(); - window.gon = originalGon; - }); - - const findGlBadgeClickWrapper = () => wrapper.findByTestId('badge-click-wrapper'); - const findGlBadge = () => wrapper.findComponent(GlBadge); - - describe.each` - root | description - ${'/'} | ${'not used (uses its own (sub)domain)'} - ${'/gitlab'} | ${'custom path'} - ${'/service/gitlab'} | ${'custom path with 2 depth'} - `('path for version_check.json', ({ root, description }) => { - describe(`when relative url is ${description}: ${root}`, () => { - beforeEach(async () => { - originalGon = window.gon; - window.gon = { ...dummyGon }; - window.gon.relative_url_root = root; - createComponent(defaultResponse); - await waitForPromises(); // Ensure we wrap up the axios call - }); - - it('reflects the relative url setting', () => { - expect(mock.history.get.length).toBe(1); - - const pathRegex = new RegExp(`^${root}`); - expect(mock.history.get[0].url).toMatch(pathRegex); - }); - }); - }); - - describe('template', () => { - describe.each` - description | mockResponse | renders - ${'successful but null'} | ${{ code: 200, res: null }} | ${false} - ${'successful and valid'} | ${{ code: 200, res: { severity: 'success' } }} | ${true} - ${'an error'} | ${{ code: 500, res: null }} | ${false} - `('version_check.json response', ({ description, mockResponse, renders }) => { - describe(`is ${description}`, () => { - beforeEach(async () => { - createComponent(mockResponse); - await waitForPromises(); // Ensure we wrap up the axios call - }); - - it(`does${renders ? '' : ' not'} render Badge Click Wrapper and GlBadge`, () => { - expect(findGlBadgeClickWrapper().exists()).toBe(renders); - expect(findGlBadge().exists()).toBe(renders); - }); - }); - }); - - describe.each` - mockResponse | expectedUI - ${{ code: 200, res: { severity: 'success' } }} | ${{ title: 'Up to date', variant: 'success' }} - ${{ code: 200, res: { severity: 'warning' } }} | ${{ title: 'Update available', variant: 'warning' }} - ${{ code: 200, res: { severity: 'danger' } }} | ${{ title: 'Update ASAP', variant: 'danger' }} - `('badge ui', ({ mockResponse, expectedUI }) => { - describe(`when response is ${mockResponse.res.severity}`, () => { - let trackingSpy; - - beforeEach(async () => { - createComponent(mockResponse); - trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - await waitForPromises(); // Ensure we wrap up the axios call - }); - - it(`title is ${expectedUI.title}`, () => { - expect(findGlBadge().text()).toBe(expectedUI.title); - }); - - it(`variant is ${expectedUI.variant}`, () => { - expect(findGlBadge().attributes('variant')).toBe(expectedUI.variant); - }); - - it(`tracks rendered_version_badge with label ${expectedUI.title}`, () => { - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'rendered_version_badge', { - label: expectedUI.title, - }); - }); - - it(`link is ${UPGRADE_DOCS_URL}`, () => { - expect(findGlBadge().attributes('href')).toBe(UPGRADE_DOCS_URL); - }); - - it(`tracks click_version_badge with label ${expectedUI.title} when badge is clicked`, async () => { - await findGlBadgeClickWrapper().trigger('click'); - - expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_version_badge', { - label: expectedUI.title, - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/group_select/group_select_spec.js b/spec/frontend/vue_shared/components/group_select/group_select_spec.js new file mode 100644 index 00000000000..f959d2225fa --- /dev/null +++ b/spec/frontend/vue_shared/components/group_select/group_select_spec.js @@ -0,0 +1,202 @@ +import { nextTick } from 'vue'; +import { GlListbox } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; +import { createAlert } from '~/flash'; +import GroupSelect from '~/vue_shared/components/group_select/group_select.vue'; +import { + TOGGLE_TEXT, + FETCH_GROUPS_ERROR, + FETCH_GROUP_ERROR, + QUERY_TOO_SHORT_MESSAGE, +} from '~/vue_shared/components/group_select/constants'; +import waitForPromises from 'helpers/wait_for_promises'; + +jest.mock('~/flash'); + +describe('GroupSelect', () => { + let wrapper; + let mock; + + // Mocks + const groupMock = { + full_name: 'selectedGroup', + id: '1', + }; + const groupEndpoint = `/api/undefined/groups/${groupMock.id}`; + + // Props + const inputName = 'inputName'; + const inputId = 'inputId'; + + // Finders + const findListbox = () => wrapper.findComponent(GlListbox); + const findInput = () => wrapper.findByTestId('input'); + + // Helpers + const createComponent = ({ props = {} } = {}) => { + wrapper = shallowMountExtended(GroupSelect, { + propsData: { + inputName, + inputId, + ...props, + }, + }); + }; + const openListbox = () => findListbox().vm.$emit('shown'); + const search = (searchString) => findListbox().vm.$emit('search', searchString); + const createComponentWithGroups = () => { + mock.onGet('/api/undefined/groups.json').reply(200, [groupMock]); + createComponent(); + openListbox(); + return waitForPromises(); + }; + const selectGroup = () => { + findListbox().vm.$emit('select', groupMock.id); + return nextTick(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('on mount', () => { + it('fetches groups when the listbox is opened', async () => { + createComponent(); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(0); + + openListbox(); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + }); + + describe('with an initial selection', () => { + it('if the selected group is not part of the fetched list, fetches it individually', async () => { + mock.onGet(groupEndpoint).reply(200, groupMock); + createComponent({ props: { initialSelection: groupMock.id } }); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + expect(findListbox().props('toggleText')).toBe(groupMock.full_name); + }); + + it('show an error if fetching the individual group fails', async () => { + mock + .onGet('/api/undefined/groups.json') + .reply(200, [{ full_name: 'notTheSelectedGroup', id: '2' }]); + mock.onGet(groupEndpoint).reply(500); + createComponent({ props: { initialSelection: groupMock.id } }); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: FETCH_GROUP_ERROR, + error: expect.any(Error), + parent: wrapper.vm.$el, + }); + }); + }); + }); + + it('shows an error when fetching groups fails', async () => { + mock.onGet('/api/undefined/groups.json').reply(500); + createComponent(); + openListbox(); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: FETCH_GROUPS_ERROR, + error: expect.any(Error), + parent: wrapper.vm.$el, + }); + }); + + describe('selection', () => { + it('uses the default toggle text while no group is selected', async () => { + await createComponentWithGroups(); + + expect(findListbox().props('toggleText')).toBe(TOGGLE_TEXT); + }); + + describe('once a group is selected', () => { + it(`uses the selected group's name as the toggle text`, async () => { + await createComponentWithGroups(); + await selectGroup(); + + expect(findListbox().props('toggleText')).toBe(groupMock.full_name); + }); + + it(`uses the selected group's ID as the listbox' and input value`, async () => { + await createComponentWithGroups(); + await selectGroup(); + + expect(findListbox().attributes('selected')).toBe(groupMock.id); + expect(findInput().attributes('value')).toBe(groupMock.id); + }); + + it(`on reset, falls back to the default toggle text`, async () => { + await createComponentWithGroups(); + await selectGroup(); + + findListbox().vm.$emit('reset'); + await nextTick(); + + expect(findListbox().props('toggleText')).toBe(TOGGLE_TEXT); + }); + }); + }); + + describe('search', () => { + it('sets `searching` to `true` when first opening the dropdown', async () => { + createComponent(); + + expect(findListbox().props('searching')).toBe(false); + + openListbox(); + await nextTick(); + + expect(findListbox().props('searching')).toBe(true); + }); + + it('sets `searching` to `true` while searching', async () => { + await createComponentWithGroups(); + + expect(findListbox().props('searching')).toBe(false); + + search('foo'); + await nextTick(); + + expect(findListbox().props('searching')).toBe(true); + }); + + it('fetches groups matching the search string', async () => { + const searchString = 'searchString'; + await createComponentWithGroups(); + + expect(mock.history.get).toHaveLength(1); + + search(searchString); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(2); + expect(mock.history.get[1].params).toStrictEqual({ search: searchString }); + }); + + it('shows a notice if the search query is too short', async () => { + const searchString = 'a'; + await createComponentWithGroups(); + search(searchString); + await waitForPromises(); + + expect(mock.history.get).toHaveLength(1); + expect(findListbox().props('noResultsText')).toBe(QUERY_TOO_SHORT_MESSAGE); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index 6fd5ae0e946..77c03dc0c3c 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -96,6 +96,20 @@ describe('HelpPopover', () => { }); }); + describe('with alternative icon', () => { + beforeEach(() => { + createComponent({ + props: { + icon: 'information-o', + }, + }); + }); + + it('uses the given icon', () => { + expect(findQuestionButton().props('icon')).toBe('information-o'); + }); + }); + describe('with custom slots', () => { const titleSlot = '<h1>title</h1>'; const defaultSlot = '<strong>content</strong>'; diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index f7e93f45148..625e67c7cc1 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -27,7 +27,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const formFieldAriaLabel = 'Edit your content'; let mock; - const buildWrapper = ({ propsData = {}, attachTo } = {}) => { + const buildWrapper = ({ propsData = {}, attachTo, stubs = {} } = {}) => { wrapper = mountExtended(MarkdownEditor, { attachTo, propsData: { @@ -45,6 +45,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }, stubs: { BubbleMenu: stubComponent(BubbleMenu), + ...stubs, }, }); }; @@ -138,9 +139,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(wrapper.emitted('input')).toEqual([[newValue]]); }); - describe('when initOnAutofocus is true', () => { + describe('when autofocus is true', () => { beforeEach(async () => { - buildWrapper({ attachTo: document.body, propsData: { initOnAutofocus: true } }); + buildWrapper({ attachTo: document.body, propsData: { autofocus: true } }); await nextTick(); }); @@ -171,7 +172,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { renderMarkdown: expect.any(Function), uploadsPath: window.uploads_path, markdown: value, - autofocus: 'end', }), ); }); @@ -204,10 +204,12 @@ describe('vue_shared/component/markdown/markdown_editor', () => { findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); }); - describe('when initOnAutofocus is true', () => { + describe('when autofocus is true', () => { beforeEach(() => { - buildWrapper({ propsData: { initOnAutofocus: true } }); - findLocalStorageSync().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + buildWrapper({ + propsData: { autofocus: true }, + stubs: { ContentEditor: stubComponent(ContentEditor) }, + }); }); it('sets the content editor autofocus property to end', () => { @@ -247,19 +249,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { it('updates localStorage value', () => { expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD); }); - - it('sets the textarea as the activeElement in the document', async () => { - // The component should be rebuilt to attach it to the document body - buildWrapper({ attachTo: document.body }); - await findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); - - expect(findContentEditor().exists()).toBe(true); - - await findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); - await findSegmentedControl().vm.$emit('change', EDITING_MODE_MARKDOWN_FIELD); - - expect(document.activeElement).toBe(findTextarea().element); - }); }); describe('when content editor emits loading event', () => { diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js new file mode 100644 index 00000000000..8edcb905096 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js @@ -0,0 +1,205 @@ +import { GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import MarkdownDrawer, { cache } from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; +import { getRenderedMarkdown } from '~/vue_shared/components/markdown_drawer/utils/fetch'; +import { contentTop } from '~/lib/utils/common_utils'; + +jest.mock('~/vue_shared/components/markdown_drawer/utils/fetch', () => ({ + getRenderedMarkdown: jest.fn().mockReturnValue({ + title: 'test title test', + body: `<div id="content-body"> + <div class="documentation md gl-mt-3"> + test body + </div> + </div>`, + }), +})); + +jest.mock('~/lib/utils/common_utils', () => ({ + contentTop: jest.fn(), +})); + +describe('MarkdownDrawer', () => { + let wrapper; + const defaultProps = { + documentPath: 'user/search/global_search/advanced_search_syntax.json', + }; + + const createComponent = (props) => { + wrapper = shallowMountExtended(MarkdownDrawer, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + Object.keys(cache).forEach((key) => delete cache[key]); + }); + + const findDrawer = () => wrapper.findComponent(GlDrawer); + const findAlert = () => wrapper.findComponent(GlAlert); + const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader); + const findDrawerTitle = () => wrapper.findComponent('[data-testid="title-element"]'); + const findDrawerBody = () => wrapper.findComponent({ ref: 'content-element' }); + + describe('component', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders correctly', () => { + expect(findDrawer().exists()).toBe(true); + expect(findDrawerTitle().text()).toBe('test title test'); + expect(findDrawerBody().text()).toBe('test body'); + }); + }); + + describe.each` + hasNavbar | navbarHeight + ${false} | ${0} + ${true} | ${100} + `('computes offsetTop', ({ hasNavbar, navbarHeight }) => { + beforeEach(() => { + global.document.querySelector = jest.fn(() => + hasNavbar + ? { + dataset: { + page: 'test', + }, + } + : undefined, + ); + contentTop.mockReturnValue(navbarHeight); + createComponent(); + }); + + afterEach(() => { + contentTop.mockClear(); + }); + + it(`computes offsetTop ${hasNavbar ? 'with' : 'without'} .navbar-gitlab`, () => { + expect(findDrawer().attributes('headerheight')).toBe(`${navbarHeight}px`); + }); + }); + + describe('watcher', () => { + let renderGLFMSpy; + let fetchMarkdownSpy; + + beforeEach(async () => { + renderGLFMSpy = jest.spyOn(MarkdownDrawer.methods, 'renderGLFM'); + fetchMarkdownSpy = jest.spyOn(MarkdownDrawer.methods, 'fetchMarkdown'); + global.document.querySelector = jest.fn(() => ({ + getBoundingClientRect: jest.fn(() => ({ bottom: 100 })), + dataset: { + page: 'test', + }, + })); + createComponent(); + await nextTick(); + }); + + afterEach(() => { + renderGLFMSpy.mockClear(); + fetchMarkdownSpy.mockClear(); + }); + + it('for documentPath triggers fetch', async () => { + expect(fetchMarkdownSpy).toHaveBeenCalledTimes(1); + + await wrapper.setProps({ documentPath: '/test/me' }); + await nextTick(); + + expect(fetchMarkdownSpy).toHaveBeenCalledTimes(2); + }); + + it('for open triggers renderGLFM', async () => { + wrapper.vm.fetchMarkdown(); + wrapper.vm.openDrawer(); + await nextTick(); + expect(renderGLFMSpy).toHaveBeenCalled(); + }); + }); + + describe('Markdown fetching', () => { + let renderGLFMSpy; + + beforeEach(async () => { + renderGLFMSpy = jest.spyOn(MarkdownDrawer.methods, 'renderGLFM'); + createComponent(); + await nextTick(); + }); + + afterEach(() => { + renderGLFMSpy.mockClear(); + }); + + it('fetches the Markdown and caches it', async () => { + expect(getRenderedMarkdown).toHaveBeenCalledTimes(1); + expect(Object.keys(cache)).toHaveLength(1); + }); + + it('when the document changes, fetches it and caches it as well', async () => { + expect(getRenderedMarkdown).toHaveBeenCalledTimes(1); + expect(Object.keys(cache)).toHaveLength(1); + + await wrapper.setProps({ documentPath: '/test/me2' }); + await nextTick(); + + expect(getRenderedMarkdown).toHaveBeenCalledTimes(2); + expect(Object.keys(cache)).toHaveLength(2); + }); + + it('when re-using an already fetched document, gets it from the cache', async () => { + await wrapper.setProps({ documentPath: '/test/me2' }); + await nextTick(); + + expect(getRenderedMarkdown).toHaveBeenCalledTimes(2); + expect(Object.keys(cache)).toHaveLength(2); + + await wrapper.setProps({ documentPath: defaultProps.documentPath }); + await nextTick(); + + expect(getRenderedMarkdown).toHaveBeenCalledTimes(2); + expect(Object.keys(cache)).toHaveLength(2); + }); + }); + + describe('Markdown fetching returns error', () => { + beforeEach(async () => { + getRenderedMarkdown.mockReturnValue({ + hasFetchError: true, + }); + + createComponent(); + await nextTick(); + }); + afterEach(() => { + getRenderedMarkdown.mockClear(); + }); + it('shows alert', () => { + expect(findAlert().exists()).toBe(true); + }); + }); + + describe('While Markdown is fetching', () => { + beforeEach(async () => { + getRenderedMarkdown.mockReturnValue(new Promise(() => {})); + + createComponent(); + }); + + afterEach(() => { + getRenderedMarkdown.mockClear(); + }); + + it('shows skeleton', async () => { + expect(findSkeleton().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown_drawer/mock_data.js b/spec/frontend/vue_shared/components/markdown_drawer/mock_data.js new file mode 100644 index 00000000000..53b40407556 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown_drawer/mock_data.js @@ -0,0 +1,42 @@ +export const MOCK_HTML = `<!DOCTYPE html> +<html> +<body> + <div id="content-body"> + <h1>test title <strong>test</strong></h1> + <div class="documentation md gl-mt-3"> + <a href="../advanced_search.md">Advanced Search</a> + <a href="../advanced_search2.md">Advanced Search2</a> + <h2>test header h2</h2> + <table class="testClass"> + <tr> + <td>Emil</td> + <td>Tobias</td> + <td>Linus</td> + </tr> + <tr> + <td>16</td> + <td>14</td> + <td>10</td> + </tr> + </table> + </div> + </div> +</body> +</html>`.replace(/\n/g, ''); + +export const MOCK_DRAWER_DATA = { + hasFetchError: false, + title: 'test title test', + body: ` <div id="content-body"> <div class="documentation md gl-mt-3"> <a href="../advanced_search.md">Advanced Search</a> <a href="../advanced_search2.md">Advanced Search2</a> <h2>test header h2</h2> <table class="testClass"> <tbody><tr> <td>Emil</td> <td>Tobias</td> <td>Linus</td> </tr> <tr> <td>16</td> <td>14</td> <td>10</td> </tr> </tbody></table> </div> </div>`, +}; + +export const MOCK_DRAWER_DATA_ERROR = { + hasFetchError: true, +}; + +export const MOCK_TABLE_DATA_BEFORE = `<head></head><body><h1>test</h1></test><table><tbody><tr><td></td></tr></tbody></table></body>`; + +export const MOCK_HTML_DATA_AFTER = { + body: '<table><tbody><tr><td></td></tr></tbody></table>', + title: 'test', +}; diff --git a/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js new file mode 100644 index 00000000000..ff07b2cf838 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js @@ -0,0 +1,43 @@ +import MockAdapter from 'axios-mock-adapter'; +import { + getRenderedMarkdown, + splitDocument, +} from '~/vue_shared/components/markdown_drawer/utils/fetch'; +import axios from '~/lib/utils/axios_utils'; +import { + MOCK_HTML, + MOCK_DRAWER_DATA, + MOCK_DRAWER_DATA_ERROR, + MOCK_TABLE_DATA_BEFORE, + MOCK_HTML_DATA_AFTER, +} from '../mock_data'; + +describe('utils/fetch', () => { + let mock; + + afterEach(() => { + mock.restore(); + }); + + describe.each` + axiosMock | type | toExpect + ${{ code: 200, res: { html: MOCK_HTML } }} | ${'success'} | ${MOCK_DRAWER_DATA} + ${{ code: 500, res: null }} | ${'error'} | ${MOCK_DRAWER_DATA_ERROR} + `('process markdown data', ({ axiosMock, type, toExpect }) => { + describe(`if api fetch responds with ${type}`, () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet().reply(axiosMock.code, axiosMock.res); + }); + it(`should update drawer correctly`, async () => { + expect(await getRenderedMarkdown('/any/path')).toStrictEqual(toExpect); + }); + }); + }); + + describe('splitDocument', () => { + it(`should update tables correctly`, () => { + expect(splitDocument(MOCK_TABLE_DATA_BEFORE)).toStrictEqual(MOCK_HTML_DATA_AFTER); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/namespace_select/mock_data.js b/spec/frontend/vue_shared/components/namespace_select/mock_data.js deleted file mode 100644 index cfd521c67cb..00000000000 --- a/spec/frontend/vue_shared/components/namespace_select/mock_data.js +++ /dev/null @@ -1,6 +0,0 @@ -export const groupNamespaces = [ - { id: 1, name: 'Group 1', humanName: 'Group 1' }, - { id: 2, name: 'Subgroup 1', humanName: 'Group 1 / Subgroup 1' }, -]; - -export const userNamespaces = [{ id: 3, name: 'User namespace 1', humanName: 'User namespace 1' }]; diff --git a/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js b/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js deleted file mode 100644 index d930ef63dad..00000000000 --- a/spec/frontend/vue_shared/components/namespace_select/namespace_select_deprecated_spec.js +++ /dev/null @@ -1,236 +0,0 @@ -import { nextTick } from 'vue'; -import { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlIntersectionObserver, - GlLoadingIcon, -} from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import NamespaceSelect, { - i18n, - EMPTY_NAMESPACE_ID, -} from '~/vue_shared/components/namespace_select/namespace_select_deprecated.vue'; -import { userNamespaces, groupNamespaces } from './mock_data'; - -const FLAT_NAMESPACES = [...userNamespaces, ...groupNamespaces]; -const EMPTY_NAMESPACE_TITLE = 'Empty namespace TEST'; -const EMPTY_NAMESPACE_ITEM = { id: EMPTY_NAMESPACE_ID, humanName: EMPTY_NAMESPACE_TITLE }; - -describe('NamespaceSelectDeprecated', () => { - let wrapper; - - const createComponent = (props = {}) => - shallowMountExtended(NamespaceSelect, { - propsData: { - userNamespaces, - groupNamespaces, - ...props, - }, - stubs: { - // We have to "full" mount GlDropdown so that slot children will render - GlDropdown, - }, - }); - - const wrappersText = (arr) => arr.wrappers.map((w) => w.text()); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownText = () => findDropdown().props('text'); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findGroupDropdownItems = () => - wrapper.findByTestId('namespace-list-groups').findAllComponents(GlDropdownItem); - const findDropdownItemsTexts = () => findDropdownItems().wrappers.map((x) => x.text()); - const findSectionHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); - const search = (term) => findSearchBox().vm.$emit('input', term); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('default', () => { - beforeEach(() => { - wrapper = createComponent(); - }); - - it('renders the dropdown', () => { - expect(findDropdown().exists()).toBe(true); - }); - - it('renders each dropdown item', () => { - expect(findDropdownItemsTexts()).toEqual(FLAT_NAMESPACES.map((x) => x.humanName)); - }); - - it('renders default dropdown text', () => { - expect(findDropdownText()).toBe(i18n.DEFAULT_TEXT); - }); - - it('splits group and user namespaces', () => { - const headers = findSectionHeaders(); - expect(wrappersText(headers)).toEqual([i18n.USERS, i18n.GROUPS]); - }); - - it('does not render wrapper as full width', () => { - expect(findDropdown().attributes('block')).toBeUndefined(); - }); - }); - - it('with defaultText, it overrides dropdown text', () => { - const textOverride = 'Select an option'; - - wrapper = createComponent({ defaultText: textOverride }); - - expect(findDropdownText()).toBe(textOverride); - }); - - it('with includeHeaders=false, hides group/user headers', () => { - wrapper = createComponent({ includeHeaders: false }); - - expect(findSectionHeaders()).toHaveLength(0); - }); - - it('with fullWidth=true, sets the dropdown to full width', () => { - wrapper = createComponent({ fullWidth: true }); - - expect(findDropdown().attributes('block')).toBe('true'); - }); - - describe('with search', () => { - it.each` - term | includeEmptyNamespace | shouldFilterNamespaces | expectedItems - ${''} | ${false} | ${true} | ${[...userNamespaces, ...groupNamespaces]} - ${'sub'} | ${false} | ${true} | ${[groupNamespaces[1]]} - ${'User'} | ${false} | ${true} | ${[...userNamespaces]} - ${'User'} | ${true} | ${true} | ${[...userNamespaces]} - ${'namespace'} | ${true} | ${true} | ${[EMPTY_NAMESPACE_ITEM, ...userNamespaces]} - ${'sub'} | ${false} | ${false} | ${[...userNamespaces, ...groupNamespaces]} - `( - 'with term=$term, includeEmptyNamespace=$includeEmptyNamespace, and shouldFilterNamespaces=$shouldFilterNamespaces should show $expectedItems.length', - async ({ term, includeEmptyNamespace, shouldFilterNamespaces, expectedItems }) => { - wrapper = createComponent({ - includeEmptyNamespace, - emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE, - shouldFilterNamespaces, - }); - - search(term); - - await nextTick(); - - const expected = expectedItems.map((x) => x.humanName); - - expect(findDropdownItemsTexts()).toEqual(expected); - }, - ); - }); - - describe('when search is typed in', () => { - it('emits `search` event', async () => { - wrapper = createComponent(); - - wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'foo'); - - await nextTick(); - - expect(wrapper.emitted('search')).toEqual([['foo']]); - }); - }); - - describe('with a selected namespace', () => { - const selectedGroupIndex = 1; - const selectedItem = groupNamespaces[selectedGroupIndex]; - - beforeEach(() => { - wrapper = createComponent(); - - wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'foo'); - findGroupDropdownItems().at(selectedGroupIndex).vm.$emit('click'); - }); - - it('sets the dropdown text', () => { - expect(findDropdownText()).toBe(selectedItem.humanName); - }); - - it('emits the `select` event when a namespace is selected', () => { - const args = [selectedItem]; - expect(wrapper.emitted('select')).toEqual([args]); - }); - - it('clears search', () => { - expect(wrapper.findComponent(GlSearchBoxByType).props('value')).toBe(''); - }); - }); - - describe('with an empty namespace option', () => { - beforeEach(() => { - wrapper = createComponent({ - includeEmptyNamespace: true, - emptyNamespaceTitle: EMPTY_NAMESPACE_TITLE, - }); - }); - - it('includes the empty namespace', () => { - const first = findDropdownItems().at(0); - - expect(first.text()).toBe(EMPTY_NAMESPACE_TITLE); - }); - - it('emits the `select` event when a namespace is selected', () => { - findDropdownItems().at(0).vm.$emit('click'); - - expect(wrapper.emitted('select')).toEqual([[EMPTY_NAMESPACE_ITEM]]); - }); - - it.each` - desc | term | shouldShow - ${'should hide empty option'} | ${'group'} | ${false} - ${'should show empty option'} | ${'Empty'} | ${true} - `('when search for $term, $desc', async ({ term, shouldShow }) => { - search(term); - - await nextTick(); - - expect(findDropdownItemsTexts().includes(EMPTY_NAMESPACE_TITLE)).toBe(shouldShow); - }); - }); - - describe('when `hasNextPageOfGroups` prop is `true`', () => { - it('renders `GlIntersectionObserver` and emits `load-more-groups` event when bottom is reached', () => { - wrapper = createComponent({ hasNextPageOfGroups: true }); - - const intersectionObserver = wrapper.findComponent(GlIntersectionObserver); - - intersectionObserver.vm.$emit('appear'); - - expect(intersectionObserver.exists()).toBe(true); - expect(wrapper.emitted('load-more-groups')).toEqual([[]]); - }); - - describe('when `isLoading` prop is `true`', () => { - it('renders a loading icon', () => { - wrapper = createComponent({ hasNextPageOfGroups: true, isLoading: true }); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - }); - }); - - describe('when `isSearchLoading` prop is `true`', () => { - it('sets `isLoading` prop to `true`', () => { - wrapper = createComponent({ isSearchLoading: true }); - - expect(wrapper.findComponent(GlSearchBoxByType).props('isLoading')).toBe(true); - }); - }); - - describe('when dropdown is opened', () => { - it('emits `show` event', () => { - wrapper = createComponent(); - - findDropdown().vm.$emit('show'); - - expect(wrapper.emitted('show')).toEqual([[]]); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js index 9b1316677d7..d531147c0e6 100644 --- a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js @@ -37,6 +37,7 @@ const mockProps = { dropdownButtonTitle: 'Move issuable', dropdownHeaderTitle: 'Move issuable', moveInProgress: false, + disabled: false, }; const mockEvent = { @@ -44,20 +45,21 @@ const mockEvent = { preventDefault: jest.fn(), }; -const createComponent = (propsData = mockProps) => - shallowMount(IssuableMoveDropdown, { - propsData, - }); - describe('IssuableMoveDropdown', () => { let mock; let wrapper; - beforeEach(() => { - mock = new MockAdapter(axios); - wrapper = createComponent(); + const createComponent = (propsData = mockProps) => { + wrapper = shallowMount(IssuableMoveDropdown, { + propsData, + }); wrapper.vm.$refs.dropdown.hide = jest.fn(); wrapper.vm.$refs.searchInput.focusInput = jest.fn(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + createComponent(); }); afterEach(() => { @@ -194,6 +196,12 @@ describe('IssuableMoveDropdown', () => { expect(findDropdownEl().findComponent(GlDropdownForm).exists()).toBe(true); }); + it('renders disabled dropdown when `disabled` is true', () => { + createComponent({ ...mockProps, disabled: true }); + + expect(findDropdownEl().attributes('disabled')).toBe('true'); + }); + it('renders header element', () => { const headerEl = findDropdownEl().find('[data-testid="header"]'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index b58c44645d6..74ddd07d041 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -49,7 +49,6 @@ describe('LabelsSelectRoot', () => { issuableType = IssuableType.Issue, queryHandler = successfulQueryHandler, mutationHandler = successfulMutationHandler, - isRealtimeEnabled = false, } = {}) => { const mockApollo = createMockApollo([ [issueLabelsQuery, queryHandler], @@ -74,9 +73,6 @@ describe('LabelsSelectRoot', () => { allowLabelEdit: true, allowLabelCreate: true, labelsManagePath: 'test', - glFeatures: { - realtimeLabels: isRealtimeEnabled, - }, }, }); }; @@ -204,17 +200,10 @@ describe('LabelsSelectRoot', () => { }); }); - it('does not emit `updateSelectedLabels` event when the subscription is triggered and FF is disabled', async () => { + it('emits `updateSelectedLabels` event when the subscription is triggered', async () => { createComponent(); await waitForPromises(); - expect(wrapper.emitted('updateSelectedLabels')).toBeUndefined(); - }); - - it('emits `updateSelectedLabels` event when the subscription is triggered and FF is enabled', async () => { - createComponent({ isRealtimeEnabled: true }); - await waitForPromises(); - expect(wrapper.emitted('updateSelectedLabels')).toEqual([ [ { 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 index 8dc3348acfa..d720574ce6d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -2,6 +2,9 @@ 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'; +import { scrollToElement } from '~/lib/utils/common_utils'; + +jest.mock('~/lib/utils/common_utils'); const DEFAULT_PROPS = { chunkIndex: 2, @@ -13,11 +16,17 @@ const DEFAULT_PROPS = { blamePath: 'blame/file.js', }; +const hash = '#L142'; + describe('Chunk component', () => { let wrapper; + let idleCallbackSpy; const createComponent = (props = {}) => { - wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } }); + wrapper = shallowMountExtended(Chunk, { + mocks: { $route: { hash } }, + propsData: { ...DEFAULT_PROPS, ...props }, + }); }; const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); @@ -26,6 +35,7 @@ describe('Chunk component', () => { const findContent = () => wrapper.findByTestId('content'); beforeEach(() => { + idleCallbackSpy = jest.spyOn(window, 'requestIdleCallback').mockImplementation((fn) => fn()); createComponent(); }); @@ -51,18 +61,30 @@ describe('Chunk component', () => { }); describe('rendering', () => { + it('does not register window.requestIdleCallback if isFirstChunk prop is true, renders lines immediately', () => { + jest.clearAllMocks(); + createComponent({ isFirstChunk: true }); + + expect(window.requestIdleCallback).not.toHaveBeenCalled(); + expect(findContent().exists()).toBe(true); + }); + it('does not render a Chunk Line component if isHighlighted is false', () => { expect(findChunkLines().length).toBe(0); }); + it('does not render simplified line numbers and content if browser is not in idle state', () => { + idleCallbackSpy.mockRestore(); + createComponent(); + + expect(findLineNumbers()).toHaveLength(0); + expect(findContent().exists()).toBe(false); + }); + 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(findLineNumbers().at(0).attributes('id')).toBe(`L${DEFAULT_PROPS.startingFrom + 1}`); expect(findContent().text()).toBe(DEFAULT_PROPS.content); }); @@ -80,5 +102,14 @@ describe('Chunk component', () => { blamePath: DEFAULT_PROPS.blamePath, }); }); + + it('does not scroll to route hash if last chunk is not loaded', () => { + expect(scrollToElement).not.toHaveBeenCalled(); + }); + + it('scrolls to route hash if last chunk is loaded', () => { + createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); + expect(scrollToElement).toHaveBeenCalledWith(hash, { behavior: 'auto' }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js index 375b1307616..a7b55d7332f 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js @@ -1,10 +1,26 @@ import packageJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'; +import godepsJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker'; import gemspecLinker from '~/vue_shared/components/source_viewer/plugins/utils/gemspec_linker'; +import gemfileLinker from '~/vue_shared/components/source_viewer/plugins/utils/gemfile_linker'; +import podspecJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker'; +import composerJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/composer_json_linker'; import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies'; -import { PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT, GEMSPEC_FILE_TYPE } from './mock_data'; +import { + PACKAGE_JSON_FILE_TYPE, + PACKAGE_JSON_CONTENT, + GEMSPEC_FILE_TYPE, + GODEPS_JSON_FILE_TYPE, + GEMFILE_FILE_TYPE, + PODSPEC_JSON_FILE_TYPE, + COMPOSER_JSON_FILE_TYPE, +} from './mock_data'; jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'); jest.mock('~/vue_shared/components/source_viewer/plugins/utils/gemspec_linker'); +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker'); +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/gemfile_linker'); +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker'); +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/composer_json_linker'); describe('Highlight.js plugin for linking dependencies', () => { const hljsResultMock = { value: 'test' }; @@ -18,4 +34,24 @@ describe('Highlight.js plugin for linking dependencies', () => { linkDependencies(hljsResultMock, GEMSPEC_FILE_TYPE); expect(gemspecLinker).toHaveBeenCalled(); }); + + it('calls godepsJsonLinker for godeps_json file types', () => { + linkDependencies(hljsResultMock, GODEPS_JSON_FILE_TYPE); + expect(godepsJsonLinker).toHaveBeenCalled(); + }); + + it('calls gemfileLinker for gemfile file types', () => { + linkDependencies(hljsResultMock, GEMFILE_FILE_TYPE); + expect(gemfileLinker).toHaveBeenCalled(); + }); + + it('calls podspecJsonLinker for podspec_json file types', () => { + linkDependencies(hljsResultMock, PODSPEC_JSON_FILE_TYPE); + expect(podspecJsonLinker).toHaveBeenCalled(); + }); + + it('calls composerJsonLinker for composer_json file types', () => { + linkDependencies(hljsResultMock, COMPOSER_JSON_FILE_TYPE); + expect(composerJsonLinker).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js index aa874c9c081..5455479ec71 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js @@ -1,4 +1,34 @@ export const PACKAGE_JSON_FILE_TYPE = 'package_json'; + export const PACKAGE_JSON_CONTENT = '{ "dependencies": { "@babel/core": "^7.18.5" } }'; +export const COMPOSER_JSON_EXAMPLES = { + packagist: '{ "require": { "composer/installers": "^1.2" } }', + drupal: '{ "require": { "drupal/bootstrap": "3.x-dev" } }', + withoutLink: '{ "require": { "drupal/erp_common": "dev-master" } }', +}; + export const GEMSPEC_FILE_TYPE = 'gemspec'; + +export const GODEPS_JSON_FILE_TYPE = 'godeps_json'; + +export const GEMFILE_FILE_TYPE = 'gemfile'; + +export const PODSPEC_JSON_FILE_TYPE = 'podspec_json'; + +export const PODSPEC_JSON_CONTENT = `{ + "dependencies": { + "MyCheckCore": [ + ] + }, + "subspecs": [ + { + "dependencies": { + "AFNetworking/Security": [ + ] + } + } + ] + }`; + +export const COMPOSER_JSON_FILE_TYPE = 'composer_json'; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/composer_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/composer_json_linker_spec.js new file mode 100644 index 00000000000..3ecb16ddcd0 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/composer_json_linker_spec.js @@ -0,0 +1,38 @@ +import composerJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/composer_json_linker'; +import { COMPOSER_JSON_EXAMPLES } from '../mock_data'; + +describe('Highlight.js plugin for linking composer.json dependencies', () => { + it('mutates the input value by wrapping dependency names and versions in anchors', () => { + const inputValue = + '<span class="hljs-attr">"drupal/erp_common""</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"dev-master"</span>'; + const outputValue = + '<span class="hljs-attr">"drupal/erp_common""</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"dev-master"</span>'; + const hljsResultMock = { value: inputValue }; + + const output = composerJsonLinker(hljsResultMock, COMPOSER_JSON_EXAMPLES.withoutLink); + expect(output).toBe(outputValue); + }); +}); + +const getInputValue = (dependencyString, version) => + `<span class="hljs-attr">"${dependencyString}"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"${version}"</span>`; +const getOutputValue = (dependencyString, version, expectedHref) => + `<span class="hljs-attr">"<a href="${expectedHref}" target="_blank" rel="nofollow noreferrer noopener">${dependencyString}</a>"</span>: <span class="hljs-attr">"<a href="${expectedHref}" target="_blank" rel="nofollow noreferrer noopener">${version}</a>"</span>`; + +describe('Highlight.js plugin for linking Godeps.json dependencies', () => { + it.each` + type | dependency | version | expectedHref + ${'packagist'} | ${'composer/installers'} | ${'^1.2'} | ${'https://packagist.org/packages/composer/installers'} + ${'drupal'} | ${'drupal/bootstrap'} | ${'3.x-dev'} | ${'https://www.drupal.org/project/bootstrap'} + `( + 'mutates the input value by wrapping dependency names in anchors and altering path when needed', + ({ type, dependency, version, expectedHref }) => { + const inputValue = getInputValue(dependency, version); + const outputValue = getOutputValue(dependency, version, expectedHref); + const hljsResultMock = { value: inputValue }; + + const output = composerJsonLinker(hljsResultMock, COMPOSER_JSON_EXAMPLES[type]); + expect(output).toBe(outputValue); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js index e4ce07ec668..66e2020da27 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util_spec.js @@ -1,13 +1,15 @@ import { createLink, generateHLJSOpenTag, + getObjectKeysByKeyName, } from '~/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util'; +import { PODSPEC_JSON_CONTENT } from '../mock_data'; describe('createLink', () => { it('generates a link with the correct attributes', () => { const href = 'http://test.com'; const innerText = 'testing'; - const result = `<a href="${href}" rel="nofollow noreferrer noopener">${innerText}</a>`; + const result = `<a href="${href}" target="_blank" rel="nofollow noreferrer noopener">${innerText}</a>`; expect(createLink(href, innerText)).toBe(result); }); @@ -18,7 +20,7 @@ describe('createLink', () => { const escapedHref = '<script>XSS</script>'; const href = `http://test.com/${unescapedXSS}`; const innerText = `testing${unescapedXSS}`; - const result = `<a href="http://test.com/${escapedHref}" rel="nofollow noreferrer noopener">testing${escapedPackageName}</a>`; + const result = `<a href="http://test.com/${escapedHref}" target="_blank" rel="nofollow noreferrer noopener">testing${escapedPackageName}</a>`; expect(createLink(href, innerText)).toBe(result); }); @@ -32,3 +34,11 @@ describe('generateHLJSOpenTag', () => { expect(generateHLJSOpenTag(type)).toBe(result); }); }); + +describe('getObjectKeysByKeyName method', () => { + it('gets all object keys within specified key', () => { + const acc = []; + const keys = getObjectKeysByKeyName(JSON.parse(PODSPEC_JSON_CONTENT), 'dependencies', acc); + expect(keys).toEqual(['MyCheckCore', 'AFNetworking/Security']); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemfile_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemfile_linker_spec.js new file mode 100644 index 00000000000..4e188c9af7e --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemfile_linker_spec.js @@ -0,0 +1,13 @@ +import gemfileLinker from '~/vue_shared/components/source_viewer/plugins/utils/gemfile_linker'; + +describe('Highlight.js plugin for linking gemfile dependencies', () => { + it('mutates the input value by wrapping dependency names in anchors', () => { + const inputValue = 'gem </span><span class="hljs-string">'paranoia''; + const outputValue = + 'gem </span><span class="hljs-string">'<a href="https://rubygems.org/gems/paranoia" target="_blank" rel="nofollow noreferrer noopener">paranoia</a>''; + const hljsResultMock = { value: inputValue }; + + const output = gemfileLinker(hljsResultMock); + expect(output).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemspec_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemspec_linker_spec.js index 3f74bfa117f..4b104b0bf43 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemspec_linker_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/gemspec_linker_spec.js @@ -5,7 +5,7 @@ describe('Highlight.js plugin for linking gemspec dependencies', () => { const inputValue = 's.add_dependency(<span class="hljs-string">'rugged'</span>, <span class="hljs-string">'~> 0.24.0'</span>)'; const outputValue = - 's.add_dependency(<span class="hljs-string linked">'<a href="https://rubygems.org/gems/rugged" rel="nofollow noreferrer noopener">rugged</a>'</span>, <span class="hljs-string">'~> 0.24.0'</span>)'; + 's.add_dependency(<span class="hljs-string linked">'<a href="https://rubygems.org/gems/rugged" target="_blank" rel="nofollow noreferrer noopener">rugged</a>'</span>, <span class="hljs-string">'~> 0.24.0'</span>)'; const hljsResultMock = { value: inputValue }; const output = gemspecLinker(hljsResultMock); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js new file mode 100644 index 00000000000..ea7e3936846 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker_spec.js @@ -0,0 +1,27 @@ +import godepsJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linker'; + +const getInputValue = (dependencyString) => + `<span class="hljs-attr">"ImportPath"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-string">"${dependencyString}"</span>`; +const getOutputValue = (dependencyString, expectedHref) => + `<span class="hljs-attr">"ImportPath"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-attr">"<a href="${expectedHref}" target="_blank" rel="nofollow noreferrer noopener">${dependencyString}</a>"</span>`; + +describe('Highlight.js plugin for linking Godeps.json dependencies', () => { + it.each` + dependency | expectedHref + ${'gitlab.com/group/project/path'} | ${'https://gitlab.com/group/project/_/tree/master/path'} + ${'gitlab.com/group/subgroup/project.git/path'} | ${'https://gitlab.com/group/subgroup/_/tree/master/project.git/path'} + ${'github.com/docker/docker/pkg/homedir'} | ${'https://github.com/docker/docker/tree/master/pkg/homedir'} + ${'golang.org/x/net/http2'} | ${'https://godoc.org/golang.org/x/net/http2'} + ${'gopkg.in/yaml.v1'} | ${'https://gopkg.in/yaml.v1'} + `( + 'mutates the input value by wrapping dependency names in anchors and altering path when needed', + ({ dependency, expectedHref }) => { + const inputValue = getInputValue(dependency); + const outputValue = getOutputValue(dependency, expectedHref); + const hljsResultMock = { value: inputValue }; + + const output = godepsJsonLinker(hljsResultMock); + expect(output).toBe(outputValue); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js index e83c129818c..170a44f8ee2 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/package_json_linker_spec.js @@ -6,7 +6,7 @@ describe('Highlight.js plugin for linking package.json dependencies', () => { const inputValue = '<span class="hljs-attr">"@babel/core"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"^7.18.5"</span>'; const outputValue = - '<span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">@babel/core</a>"</span>: <span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" rel="nofollow noreferrer noopener">^7.18.5</a>"</span>'; + '<span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" target="_blank" rel="nofollow noreferrer noopener">@babel/core</a>"</span>: <span class="hljs-attr">"<a href="https://npmjs.com/package/@babel/core" target="_blank" rel="nofollow noreferrer noopener">^7.18.5</a>"</span>'; const hljsResultMock = { value: inputValue }; const output = packageJsonLinker(hljsResultMock, PACKAGE_JSON_CONTENT); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker_spec.js new file mode 100644 index 00000000000..0ef63de68c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker_spec.js @@ -0,0 +1,14 @@ +import podspecJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker'; +import { PODSPEC_JSON_CONTENT } from '../mock_data'; + +describe('Highlight.js plugin for linking podspec_json dependencies', () => { + it('mutates the input value by wrapping dependency names in anchors', () => { + const inputValue = + '<span class="hljs-attr">"AFNetworking/Security"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-punctuation">['; + const outputValue = + '<span class="hljs-attr">"<a href="https://cocoapods.org/pods/AFNetworking" target="_blank" rel="nofollow noreferrer noopener">AFNetworking/Security</a>"</span><span class="hljs-punctuation">:</span><span class=""> </span><span class="hljs-punctuation">['; + const hljsResultMock = { value: inputValue }; + const output = podspecJsonLinker(hljsResultMock, PODSPEC_JSON_CONTENT); + expect(output).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js index bc6df1a2565..8d072c8c8de 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/wrap_child_nodes_spec.js @@ -8,13 +8,14 @@ describe('Highlight.js plugin for wrapping _emitter nodes', () => { children: [ { kind: 'string', children: ['Text 1'] }, { kind: 'string', children: ['Text 2', { kind: 'comment', children: ['Text 3'] }] }, + { kind: undefined, sublanguage: true, children: ['Text 3 (sublanguage)'] }, 'Text4\nText5', ], }, }, }; - const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text4</span>\n<span class="">Text5</span>`; + const outputValue = `<span class="hljs-string">Text 1</span><span class="hljs-string"><span class="hljs-string">Text 2</span><span class="hljs-comment">Text 3</span></span><span class="">Text 3 (sublanguage)</span><span class="">Text4</span>\n<span class="">Text5</span>`; wrapChildNodes(hljsResultMock); expect(hljsResultMock.value).toBe(outputValue); 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 6d319b37b02..33f370efdfa 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 @@ -10,6 +10,7 @@ import { EVENT_LABEL_VIEWER, EVENT_LABEL_FALLBACK, ROUGE_TO_HLJS_LANGUAGE_MAP, + LINES_PER_CHUNK, } from '~/vue_shared/components/source_viewer/constants'; import waitForPromises from 'helpers/wait_for_promises'; import LineHighlighter from '~/blob/line_highlighter'; @@ -121,6 +122,7 @@ describe('Source Viewer component', () => { it('highlights the first chunk', () => { expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); + expect(findChunks().at(0).props('isFirstChunk')).toBe(true); }); describe('auto-detects if a language cannot be loaded', () => { @@ -133,45 +135,27 @@ describe('Source Viewer component', () => { }); describe('rendering', () => { - it('renders the first chunk', async () => { - const firstChunk = findChunks().at(0); - - expect(firstChunk.props('content')).toContain(chunk1); - - expect(firstChunk.props()).toMatchObject({ - totalLines: 70, - startingFrom: 0, + it.each` + chunkIndex | chunkContent | totalChunks + ${0} | ${chunk1} | ${0} + ${1} | ${chunk2} | ${3} + ${2} | ${chunk3Result} | ${3} + `('renders chunk $chunkIndex', ({ chunkIndex, chunkContent, totalChunks }) => { + const chunk = findChunks().at(chunkIndex); + + expect(chunk.props('content')).toContain(chunkContent.trim()); + + expect(chunk.props()).toMatchObject({ + totalLines: LINES_PER_CHUNK, + startingFrom: LINES_PER_CHUNK * chunkIndex, + totalChunks, }); }); - it('renders the second chunk', async () => { - const secondChunk = findChunks().at(1); - - expect(secondChunk.props('content')).toContain(chunk2.trim()); - - expect(secondChunk.props()).toMatchObject({ - totalLines: 70, - startingFrom: 70, - }); + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); }); - - it('renders the third chunk', async () => { - const thirdChunk = findChunks().at(2); - - expect(thirdChunk.props('content')).toContain(chunk3Result.trim()); - - expect(chunk3Result).toEqual(chunk3.replace(/\r?\n/g, '\n')); - - expect(thirdChunk.props()).toMatchObject({ - totalLines: 70, - startingFrom: 140, - }); - }); - }); - - it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { - findChunks().at(0).vm.$emit('appear'); - expect(eventHub.$emit).toHaveBeenCalledWith('showBlobInteractionZones', path); }); describe('LineHighlighter', () => { diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index f55d3156581..e1c6020686c 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -543,24 +543,6 @@ describe('IssuableItem', () => { }); }); - describe('when issuable was created within the past 24 hours', () => { - it('renders issuable card with a recently-created style', () => { - wrapper = createComponent({ - issuable: { ...mockIssuable, createdAt: '2020-12-10T12:34:56' }, - }); - - expect(wrapper.classes()).toContain('today'); - }); - }); - - describe('when issuable was created earlier than the past 24 hours', () => { - it('renders issuable card without a recently-created style', () => { - wrapper = createComponent({ issuable: { ...mockIssuable, createdAt: '2020-12-09' } }); - - expect(wrapper.classes()).not.toContain('today'); - }); - }); - describe('scoped labels', () => { describe.each` description | labelPosition | hasScopedLabelsFeature | scoped diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 0c53f599d55..371844e66f4 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -322,6 +322,18 @@ describe('IssuableListRoot', () => { }); }); + describe('showFilteredSearchFriendlyText prop', () => { + describe.each([true, false])('when %s', (showFilteredSearchFriendlyText) => { + it('passes its value to FilteredSearchBar', () => { + wrapper = createComponent({ props: { showFilteredSearchFriendlyText } }); + + expect(findFilteredSearchBar().props('showFriendlyText')).toBe( + showFilteredSearchFriendlyText, + ); + }); + }); + }); + describe('alert', () => { const error = 'oopsie!'; |