diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-20 14:34:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-20 14:34:42 +0000 |
commit | 9f46488805e86b1bc341ea1620b866016c2ce5ed (patch) | |
tree | f9748c7e287041e37d6da49e0a29c9511dc34768 /spec/frontend/vue_shared | |
parent | dfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff) | |
download | gitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz |
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'spec/frontend/vue_shared')
59 files changed, 2610 insertions, 226 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index 4cd03a690e9..408f9d57147 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -24,12 +24,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-input-group-stub tag="div" > - <b-input-group-prepend-stub - tag="div" - > - - <!----> - </b-input-group-prepend-stub> + <!----> <b-form-input-stub class="gl-form-input" @@ -44,18 +39,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` > <gl-button-stub category="tertiary" + class="d-inline-flex" data-clipboard-text="ssh://foo.bar" - icon="" + data-qa-selector="copy_ssh_url_button" + icon="copy-to-clipboard" size="medium" title="Copy URL" variant="default" - > - <gl-icon-stub - name="copy-to-clipboard" - size="16" - title="Copy URL" - /> - </gl-button-stub> + /> </b-input-group-append-stub> </b-input-group-stub> </div> @@ -74,12 +65,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <b-input-group-stub tag="div" > - <b-input-group-prepend-stub - tag="div" - > - - <!----> - </b-input-group-prepend-stub> + <!----> <b-form-input-stub class="gl-form-input" @@ -94,18 +80,14 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` > <gl-button-stub category="tertiary" + class="d-inline-flex" data-clipboard-text="http://foo.bar" - icon="" + data-qa-selector="copy_http_url_button" + icon="copy-to-clipboard" size="medium" title="Copy URL" variant="default" - > - <gl-icon-stub - name="copy-to-clipboard" - size="16" - title="Copy URL" - /> - </gl-button-stub> + /> </b-input-group-append-stub> </b-input-group-stub> </div> diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap index 5347d1efc48..db174346729 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap @@ -1,16 +1,26 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Code Block matches snapshot 1`] = ` +exports[`Code Block with default props renders correctly 1`] = ` <pre class="code-block rounded" > - <code class="d-block" > test-code </code> - +</pre> +`; +exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = ` +<pre + class="code-block rounded" + style="max-height: 200px; overflow-y: auto;" +> + <code + class="d-block" + > + test-code + </code> </pre> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap index 72370cb5b52..1d8e04b83a3 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap @@ -1,6 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Identicon matches snapshot 1`] = ` +exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = ` +<div + class="avatar identicon s40 bg2" +> + + E + +</div> +`; + +exports[`Identicon entity id is a number matches snapshot 1`] = ` <div class="avatar identicon s40 bg2" > diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index bb3e60ab9e2..0abb72ace2e 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -210,4 +210,46 @@ describe('vue_shared/components/awards_list', () => { expect(buttons.wrappers.every(x => x.classes('disabled'))).toBe(true); }); }); + + describe('with default awards', () => { + beforeEach(() => { + createComponent({ + awards: [createAward(EMOJI_SMILE, USERS.marie), createAward(EMOJI_100, USERS.marie)], + canAwardEmoji: true, + currentUserId: USERS.root.id, + // Let's assert that it puts thumbsup and thumbsdown in the right order still + defaultAwards: [EMOJI_THUMBSDOWN, EMOJI_100, EMOJI_THUMBSUP], + }); + }); + + it('shows awards in correct order', () => { + expect(findAwardsData()).toEqual([ + { + classes: ['btn', 'award-control'], + count: 0, + html: matchingEmojiTag(EMOJI_THUMBSUP), + title: '', + }, + { + classes: ['btn', 'award-control'], + count: 0, + html: matchingEmojiTag(EMOJI_THUMBSDOWN), + title: '', + }, + // We expect the EMOJI_100 before the EMOJI_SMILE because it was given as a defaultAward + { + classes: ['btn', 'award-control'], + count: 1, + html: matchingEmojiTag(EMOJI_100), + title: 'Marie', + }, + { + classes: ['btn', 'award-control'], + count: 1, + html: matchingEmojiTag(EMOJI_SMILE), + title: 'Marie', + }, + ]); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap index 87f2a8f9eff..4909d2d4226 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap +++ b/spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap @@ -2,7 +2,8 @@ exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = ` <div - class="file-content code js-syntax-highlight qa-file-content" + class="file-content code js-syntax-highlight" + data-qa-selector="file_content" > <div class="line-numbers" diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index ce3f289eb6e..5cf42ecdc1d 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue'; +import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import { handleBlobRichViewer } from '~/blob/viewer'; jest.mock('~/blob/viewer'); @@ -33,4 +34,8 @@ describe('Blob Rich Viewer component', () => { it('queries for advanced viewer', () => { expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType); }); + + it('is using Markdown View Field', () => { + expect(wrapper.contains(MarkdownFieldView)).toBe(true); + }); }); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js new file mode 100644 index 00000000000..f656bb0b60d --- /dev/null +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import ciBadge from '~/vue_shared/components/ci_badge_link.vue'; + +describe('CI Badge Link Component', () => { + let CIBadge; + let vm; + + const statuses = { + canceled: { + text: 'canceled', + label: 'canceled', + group: 'canceled', + icon: 'status_canceled', + details_path: 'status/canceled', + }, + created: { + text: 'created', + label: 'created', + group: 'created', + icon: 'status_created', + details_path: 'status/created', + }, + failed: { + text: 'failed', + label: 'failed', + group: 'failed', + icon: 'status_failed', + details_path: 'status/failed', + }, + manual: { + text: 'manual', + label: 'manual action', + group: 'manual', + icon: 'status_manual', + details_path: 'status/manual', + }, + pending: { + text: 'pending', + label: 'pending', + group: 'pending', + icon: 'status_pending', + details_path: 'status/pending', + }, + running: { + text: 'running', + label: 'running', + group: 'running', + icon: 'status_running', + details_path: 'status/running', + }, + skipped: { + text: 'skipped', + label: 'skipped', + group: 'skipped', + icon: 'status_skipped', + details_path: 'status/skipped', + }, + success_warining: { + text: 'passed', + label: 'passed', + group: 'success-with-warnings', + icon: 'status_warning', + details_path: 'status/warning', + }, + success: { + text: 'passed', + label: 'passed', + group: 'passed', + icon: 'status_success', + details_path: 'status/passed', + }, + }; + + beforeEach(() => { + CIBadge = Vue.extend(ciBadge); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render each status badge', () => { + Object.keys(statuses).map(status => { + vm = mountComponent(CIBadge, { status: statuses[status] }); + + expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path); + expect(vm.$el.textContent.trim()).toEqual(statuses[status].text); + expect(vm.$el.getAttribute('class')).toContain(`ci-status ci-${statuses[status].group}`); + expect(vm.$el.querySelector('svg')).toBeDefined(); + return vm; + }); + }); + + it('should not render label', () => { + vm = mountComponent(CIBadge, { status: statuses.canceled, showText: false }); + + expect(vm.$el.textContent.trim()).toEqual(''); + }); +}); diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js new file mode 100644 index 00000000000..63afe631063 --- /dev/null +++ b/spec/frontend/vue_shared/components/ci_icon_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('CI Icon component', () => { + const Component = Vue.extend(ciIcon); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('should render a span element with an svg', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_success', + }, + }); + + expect(vm.$el.tagName).toEqual('SPAN'); + expect(vm.$el.querySelector('span > svg')).toBeDefined(); + }); + + it('should render a success status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_success', + group: 'success', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-success')).toEqual(true); + }); + + it('should render a failed status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_failed', + group: 'failed', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-failed')).toEqual(true); + }); + + it('should render success with warnings status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_warning', + group: 'warning', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-warning')).toEqual(true); + }); + + it('should render pending status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_pending', + group: 'pending', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-pending')).toEqual(true); + }); + + it('should render running status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_running', + group: 'running', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-running')).toEqual(true); + }); + + it('should render created status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_created', + group: 'created', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-created')).toEqual(true); + }); + + it('should render skipped status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_skipped', + group: 'skipped', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-skipped')).toEqual(true); + }); + + it('should render canceled status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_canceled', + group: 'canceled', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-canceled')).toEqual(true); + }); + + it('should render status for manual action', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_manual', + group: 'manual', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-manual')).toEqual(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js index 0d21dd94f7c..60b0b0b566b 100644 --- a/spec/frontend/vue_shared/components/code_block_spec.js +++ b/spec/frontend/vue_shared/components/code_block_spec.js @@ -4,10 +4,15 @@ import CodeBlock from '~/vue_shared/components/code_block.vue'; describe('Code Block', () => { let wrapper; - const createComponent = () => { + const defaultProps = { + code: 'test-code', + }; + + const createComponent = (props = {}) => { wrapper = shallowMount(CodeBlock, { propsData: { - code: 'test-code', + ...defaultProps, + ...props, }, }); }; @@ -17,9 +22,23 @@ describe('Code Block', () => { wrapper = null; }); - it('matches snapshot', () => { - createComponent(); + describe('with default props', () => { + beforeEach(() => { + createComponent(); + }); - expect(wrapper.element).toMatchSnapshot(); + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('with maxHeight set to "200px"', () => { + beforeEach(() => { + createComponent({ maxHeight: '200px' }); + }); + + it('renders correctly', () => { + expect(wrapper.element).toMatchSnapshot(); + }); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js new file mode 100644 index 00000000000..16e7e4dd5cc --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js @@ -0,0 +1,21 @@ +import { mount } from '@vue/test-utils'; +import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; +import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import '~/behaviors/markdown/render_gfm'; + +describe('ContentViewer', () => { + let wrapper; + + it.each` + path | type | selector | viewer + ${GREEN_BOX_IMAGE_URL} | ${'image'} | ${'img'} | ${'<image-viewer>'} + ${'myfile.md'} | ${'markdown'} | ${'.md-previewer'} | ${'<markdown-viewer>'} + ${'myfile.abc'} | ${undefined} | ${'[download]'} | ${'<download-viewer>'} + `('renders $viewer when file type="$type"', ({ path, type, selector }) => { + wrapper = mount(ContentViewer, { + propsData: { path, fileSize: 1024, type }, + }); + + expect(wrapper.find(selector).element).toExist(); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js new file mode 100644 index 00000000000..facdaa86f84 --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/lib/viewer_utils_spec.js @@ -0,0 +1,20 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; + +describe('viewerInformationForPath', () => { + it.each` + path | type + ${'p/somefile.jpg'} | ${'image'} + ${'p/somefile.jpeg'} | ${'image'} + ${'p/somefile.bmp'} | ${'image'} + ${'p/somefile.ico'} | ${'image'} + ${'p/somefile.png'} | ${'image'} + ${'p/somefile.gif'} | ${'image'} + ${'p/somefile.md'} | ${'markdown'} + ${'p/md'} | ${undefined} + ${'p/png'} | ${undefined} + ${'p/md.png/a'} | ${undefined} + ${'p/some-file.php'} | ${undefined} + `('when path=$path, type=$type', ({ path, type }) => { + expect(viewerInformationForPath(path)?.id).toBe(type); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js new file mode 100644 index 00000000000..b83602e7bfc --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/download_viewer_spec.js @@ -0,0 +1,28 @@ +import { mount } from '@vue/test-utils'; +import DownloadViewer from '~/vue_shared/components/content_viewer/viewers/download_viewer.vue'; + +describe('DownloadViewer', () => { + let wrapper; + + it.each` + path | filePath | fileSize | renderedName | renderedSize + ${'somepath/test.abc'} | ${undefined} | ${1024} | ${'test.abc'} | ${'1.00 KiB'} + ${'somepath/test.abc'} | ${undefined} | ${null} | ${'test.abc'} | ${''} + ${'data:application/unknown;base64,U0VMRUNU'} | ${'somepath/test.abc'} | ${2048} | ${'test.abc'} | ${'2.00 KiB'} + `( + 'renders the file name as "$renderedName" and shows size as "$renderedSize"', + ({ path, filePath, fileSize, renderedName, renderedSize }) => { + wrapper = mount(DownloadViewer, { + propsData: { path, filePath, fileSize }, + }); + + const renderedFileInfo = wrapper.find('.file-info').text(); + + expect(renderedFileInfo).toContain(renderedName); + expect(renderedFileInfo).toContain(renderedSize); + + expect(wrapper.find('.btn.btn-default').text()).toContain('Download'); + expect(wrapper.find('.btn.btn-default').element).toHaveAttr('download', 'test.abc'); + }, + ); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js index ef785b9f0f5..31e843297fa 100644 --- a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js @@ -1,45 +1,36 @@ -import { shallowMount } from '@vue/test-utils'; - +import { mount } from '@vue/test-utils'; import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue'; describe('Image Viewer', () => { - const requiredProps = { - path: GREEN_BOX_IMAGE_URL, - renderInfo: true, - }; let wrapper; - let imageInfo; - - function createElement({ props, includeRequired = true } = {}) { - const data = includeRequired ? { ...requiredProps, ...props } : { ...props }; - wrapper = shallowMount(ImageViewer, { - propsData: data, + it('renders image preview', () => { + wrapper = mount(ImageViewer, { + propsData: { path: GREEN_BOX_IMAGE_URL, fileSize: 1024 }, }); - imageInfo = wrapper.find('.image-info'); - } - - describe('file sizes', () => { - it('should show the humanized file size when `renderInfo` is true and there is size info', () => { - createElement({ props: { fileSize: 1024 } }); - - expect(imageInfo.text()).toContain('1.00 KiB'); - }); - - it('should not show the humanized file size when `renderInfo` is true and there is no size', () => { - const FILESIZE_RE = /\d+(\.\d+)?\s*([KMGTP]i)*B/; - createElement({ props: { fileSize: 0 } }); - - // It shouldn't show any filesize info - expect(imageInfo.text()).not.toMatch(FILESIZE_RE); - }); - - it('should not show any image information when `renderInfo` is false', () => { - createElement({ props: { renderInfo: false } }); + expect(wrapper.find('img').element).toHaveAttr('src', GREEN_BOX_IMAGE_URL); + }); - expect(imageInfo.exists()).toBe(false); - }); + describe('file sizes', () => { + it.each` + fileSize | renderInfo | elementExists | humanizedFileSize + ${1024} | ${true} | ${true} | ${'1.00 KiB'} + ${0} | ${true} | ${true} | ${''} + ${1024} | ${false} | ${false} | ${undefined} + `( + 'shows file size as "$humanizedFileSize", if fileSize=$fileSize and renderInfo=$renderInfo', + ({ fileSize, renderInfo, elementExists, humanizedFileSize }) => { + wrapper = mount(ImageViewer, { + propsData: { path: GREEN_BOX_IMAGE_URL, fileSize, renderInfo }, + }); + + const imageInfo = wrapper.find('.image-info'); + + expect(imageInfo.exists()).toBe(elementExists); + expect(imageInfo.element?.textContent.trim()).toBe(humanizedFileSize); + }, + ); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js new file mode 100644 index 00000000000..8d3fcdd48d2 --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js @@ -0,0 +1,114 @@ +import $ from 'jquery'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import { mount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import MarkdownViewer from '~/vue_shared/components/content_viewer/viewers/markdown_viewer.vue'; + +describe('MarkdownViewer', () => { + let wrapper; + let mock; + + const createComponent = props => { + wrapper = mount(MarkdownViewer, { + propsData: { + ...props, + path: 'test.md', + content: '* Test', + projectPath: 'testproject', + type: 'markdown', + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + jest.spyOn(axios, 'post'); + jest.spyOn($.fn, 'renderGFM'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + beforeEach(() => { + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, { + body: '<b>testing</b> {{gl_md_img_1}}', + }); + }); + + it('renders an animation container while the markdown is loading', () => { + createComponent(); + + expect(wrapper.find('.animation-container')).toExist(); + }); + + it('renders markdown preview preview renders and loads rendered markdown from server', () => { + createComponent(); + + return waitForPromises().then(() => { + expect(wrapper.find('.md-previewer').text()).toContain('testing'); + }); + }); + + it('receives the filePath and commitSha as a parameters and passes them on to the server', () => { + createComponent({ filePath: 'foo/test.md', commitSha: 'abcdef' }); + + expect(axios.post).toHaveBeenCalledWith( + `${gon.relative_url_root}/testproject/preview_markdown`, + { path: 'foo/test.md', text: '* Test', ref: 'abcdef' }, + expect.any(Object), + ); + }); + + it.each` + imgSrc | imgAlt + ${'data:image/jpeg;base64,AAAAAA+/'} | ${'my image title'} + ${'data:image/jpeg;base64,AAAAAA+/'} | ${'"somebody\'s image" &'} + ${'hack" onclick=alert(0)'} | ${'hack" onclick=alert(0)'} + ${'hack\\" onclick=alert(0)'} | ${'hack\\" onclick=alert(0)'} + ${"hack' onclick=alert(0)"} | ${"hack' onclick=alert(0)"} + ${"hack'><script>alert(0)</script>"} | ${"hack'><script>alert(0)</script>"} + `( + 'transforms template tags with base64 encoded images available locally', + ({ imgSrc, imgAlt }) => { + createComponent({ + images: { + '{{gl_md_img_1}}': { + src: imgSrc, + alt: imgAlt, + title: imgAlt, + }, + }, + }); + + return waitForPromises().then(() => { + const img = wrapper.find('.md-previewer img').element; + + // if the values are the same as the input, it means + // they were escaped correctly + expect(img).toHaveAttr('src', imgSrc); + expect(img).toHaveAttr('alt', imgAlt); + expect(img).toHaveAttr('title', imgAlt); + }); + }, + ); + }); + + describe('error', () => { + beforeEach(() => { + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(500, { + body: 'Internal Server Error', + }); + }); + it('renders an error message if loading the markdown preview fails', () => { + createComponent(); + + return waitForPromises().then(() => { + expect(wrapper.find('.md-previewer').text()).toContain('error'); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index 3a75ab2d127..98962918b49 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -56,13 +56,8 @@ describe('date time picker lib', () => { describe('stringToISODate', () => { ['', 'null', undefined, 'abc'].forEach(input => { - it(`throws error for invalid input like ${input}`, done => { - try { - dateTimePickerLib.stringToISODate(input); - } catch (e) { - expect(e).toBeDefined(); - done(); - } + it(`throws error for invalid input like ${input}`, () => { + expect(() => dateTimePickerLib.stringToISODate(input)).toThrow(); }); }); [ diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js new file mode 100644 index 00000000000..636508be6b6 --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -0,0 +1,98 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; +import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; + +describe('DiffViewer', () => { + const requiredProps = { + diffMode: 'replaced', + diffViewerMode: 'image', + newPath: GREEN_BOX_IMAGE_URL, + newSha: 'ABC', + oldPath: RED_BOX_IMAGE_URL, + oldSha: 'DEF', + }; + let vm; + + function createComponent(props) { + const DiffViewer = Vue.extend(diffViewer); + + vm = mountComponent(DiffViewer, props); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders image diff', done => { + window.gon = { + relative_url_root: '', + }; + + createComponent({ ...requiredProps, projectPath: '' }); + + setImmediate(() => { + expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( + `//-/raw/DEF/${RED_BOX_IMAGE_URL}`, + ); + + expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe( + `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`, + ); + + done(); + }); + }); + + it('renders fallback download diff display', done => { + createComponent({ + ...requiredProps, + diffViewerMode: 'added', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + setImmediate(() => { + expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain( + 'testold.abc', + ); + + expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc'); + expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + done(); + }); + }); + + it('renders renamed component', () => { + createComponent({ + ...requiredProps, + diffMode: 'renamed', + diffViewerMode: 'renamed', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + expect(vm.$el.textContent).toContain('File moved'); + }); + + it('renders mode changed component', () => { + createComponent({ + ...requiredProps, + diffMode: 'mode_changed', + newPath: 'test.abc', + oldPath: 'testold.abc', + aMode: '123', + bMode: '321', + }); + + expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js new file mode 100644 index 00000000000..892a96b76fd --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; + +import { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +const defaultLabel = 'Select'; +const customLabel = 'Select project'; + +const createComponent = (props, slots = {}) => { + const Component = Vue.extend(dropdownButtonComponent); + + return mountComponentWithSlots(Component, { props, slots }); +}; + +describe('DropdownButtonComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('dropdownToggleText', () => { + it('returns default toggle text', () => { + expect(vm.toggleText).toBe(defaultLabel); + }); + + it('returns custom toggle text when provided via props', () => { + const vmEmptyLabels = createComponent({ toggleText: customLabel }); + + expect(vmEmptyLabels.toggleText).toBe(customLabel); + vmEmptyLabels.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component container element of type `button`', () => { + expect(vm.$el.nodeName).toBe('BUTTON'); + }); + + it('renders component container element with required data attributes', () => { + expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); + expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); + expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); + expect(vm.$el.dataset.labels).toBe(vm.labelsPath); + expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); + expect(vm.$el.dataset.showAny).not.toBeDefined(); + }); + + it('renders dropdown toggle text element', () => { + const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); + + expect(dropdownToggleTextEl).not.toBeNull(); + expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel); + }); + + it('renders dropdown button icon', () => { + const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa'); + + expect(dropdownIconEl).not.toBeNull(); + expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); + }); + + it('renders slot, if default slot exists', () => { + vm = createComponent( + {}, + { + default: ['Lorem Ipsum Dolar'], + }, + ); + + expect(vm.$el.querySelector('.dropdown-toggle-text')).toBeNull(); + expect(vm.$el).toHaveText('Lorem Ipsum Dolar'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js new file mode 100644 index 00000000000..30b8e869aab --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; + +import { mockLabels } from './mock_data'; + +const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => { + const Component = Vue.extend(dropdownHiddenInputComponent); + + return mountComponent(Component, { + name, + value, + }); +}; + +describe('DropdownHiddenInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders input element of type `hidden`', () => { + expect(vm.$el.nodeName).toBe('INPUT'); + expect(vm.$el.getAttribute('type')).toBe('hidden'); + expect(vm.$el.getAttribute('name')).toBe(vm.name); + expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/mock_data.js b/spec/frontend/vue_shared/components/dropdown/mock_data.js new file mode 100644 index 00000000000..b09d42da401 --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/mock_data.js @@ -0,0 +1,11 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +export default mockLabels; diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js new file mode 100644 index 00000000000..63f2614106d --- /dev/null +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -0,0 +1,140 @@ +import Vue from 'vue'; +import { file } from 'jest/ide/helpers'; +import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; +import createComponent from 'helpers/vue_mount_component_helper'; + +describe('File finder item spec', () => { + const Component = Vue.extend(ItemComponent); + let vm; + let localFile; + + beforeEach(() => { + localFile = { + ...file(), + name: 'test file', + path: 'test/file', + }; + + vm = createComponent(Component, { + file: localFile, + focused: true, + searchText: '', + index: 0, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file name & path', () => { + expect(vm.$el.textContent).toContain('test file'); + expect(vm.$el.textContent).toContain('test/file'); + }); + + describe('focused', () => { + it('adds is-focused class', () => { + expect(vm.$el.classList).toContain('is-focused'); + }); + + it('does not have is-focused class when not focused', done => { + vm.focused = false; + + vm.$nextTick(() => { + expect(vm.$el.classList).not.toContain('is-focused'); + + done(); + }); + }); + }); + + describe('changed file icon', () => { + it('does not render when not a changed or temp file', () => { + expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null); + }); + + it('renders when a changed file', done => { + vm.file.changed = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + + done(); + }); + }); + + it('renders when a temp file', done => { + vm.file.tempFile = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + + done(); + }); + }); + }); + + it('emits event when clicked', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + + vm.$el.click(); + + expect(vm.$emit).toHaveBeenCalledWith('click', vm.file); + }); + + describe('path', () => { + let el; + + beforeEach(done => { + vm.searchText = 'file'; + + el = vm.$el.querySelector('.diff-changed-file-path'); + + vm.$nextTick(done); + }); + + it('highlights text', () => { + expect(el.querySelectorAll('.highlighted').length).toBe(4); + }); + + it('adds ellipsis to long text', done => { + vm.file.path = new Array(70) + .fill() + .map((_, i) => `${i}-`) + .join(''); + + vm.$nextTick(() => { + expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`); + done(); + }); + }); + }); + + describe('name', () => { + let el; + + beforeEach(done => { + vm.searchText = 'file'; + + el = vm.$el.querySelector('.diff-changed-file-name'); + + vm.$nextTick(done); + }); + + it('highlights text', () => { + expect(el.querySelectorAll('.highlighted').length).toBe(4); + }); + + it('does not add ellipsis to long text', done => { + vm.file.name = new Array(70) + .fill() + .map((_, i) => `${i}-`) + .join(''); + + vm.$nextTick(() => { + expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index 732491378fa..46df2d2aaf1 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -91,9 +91,7 @@ describe('File row component', () => { jest.spyOn(wrapper.vm, 'scrollIntoView'); wrapper.setProps({ - file: Object.assign({}, wrapper.props('file'), { - active: true, - }), + file: { ...wrapper.props('file'), active: true }, }); return nextTick().then(() => { @@ -125,9 +123,7 @@ describe('File row component', () => { it('matches the current route against encoded file URL', () => { const fileName = 'with space'; - const rowFile = Object.assign({}, file(fileName), { - url: `/${fileName}`, - }); + const rowFile = { ...file(fileName), url: `/${fileName}` }; const routerPath = `/project/${escapeFileUrl(fileName)}`; createComponent( { diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js new file mode 100644 index 00000000000..87cafa0bb8c --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js @@ -0,0 +1,190 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import component from '~/vue_shared/components/filtered_search_dropdown.vue'; + +describe('Filtered search dropdown', () => { + const Component = Vue.extend(component); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('with an empty array of items', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [], + filterKey: '', + }); + }); + + it('renders empty list', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); + }); + + it('renders filter input', () => { + expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull(); + }); + }); + + describe('when visible numbers is less than the items length', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], + visibleItems: 2, + filterKey: 'title', + }); + }); + + it('it renders only the maximum number provided', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); + }); + }); + + describe('when visible number is bigger than the items length', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], + filterKey: 'title', + }); + }); + + it('it renders the full list of items the maximum number provided', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3); + }); + }); + + describe('while filtering', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + }); + }); + + it('updates the results to match the typed value', done => { + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); + done(); + }); + }); + + describe('when no value matches the typed one', () => { + it('does not render any result', done => { + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); + done(); + }); + }); + }); + }); + + describe('with create mode enabled', () => { + describe('when there are no matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + showCreateMode: true, + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('renders a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull(); + done(); + }); + }); + + it('renders computed button text', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual( + 'Create eleven', + ); + done(); + }); + }); + + describe('on click create button', () => { + it('emits createItem event with the filter', done => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$nextTick(() => { + vm.$el.querySelector('.js-dropdown-create-button').click(); + + expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven'); + done(); + }); + }); + }); + }); + + describe('when there are matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + showCreateMode: true, + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('does not render a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); + done(); + }); + }); + }); + }); + + describe('with create mode disabled', () => { + describe('when there are no matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('does not render a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js new file mode 100644 index 00000000000..365c9fad478 --- /dev/null +++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js @@ -0,0 +1,83 @@ +import mountComponent from 'helpers/vue_mount_component_helper'; +import Vue from 'vue'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +describe('GlCountdown', () => { + const Component = Vue.extend(GlCountdown); + let vm; + let now = '2000-01-01T00:00:00Z'; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date(now).getTime()); + }); + + afterEach(() => { + vm.$destroy(); + jest.clearAllTimers(); + }); + + describe('when there is time remaining', () => { + beforeEach(done => { + vm = mountComponent(Component, { + endDateString: '2000-01-01T01:02:03Z', + }); + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays remaining time', () => { + expect(vm.$el.textContent).toContain('01:02:03'); + }); + + it('updates remaining time', done => { + now = '2000-01-01T00:00:01Z'; + jest.advanceTimersByTime(1000); + + Vue.nextTick() + .then(() => { + expect(vm.$el.textContent).toContain('01:02:02'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('when there is no time remaining', () => { + beforeEach(done => { + vm = mountComponent(Component, { + endDateString: '1900-01-01T00:00:00Z', + }); + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays 00:00:00', () => { + expect(vm.$el.textContent).toContain('00:00:00'); + }); + }); + + describe('when an invalid date is passed', () => { + beforeEach(() => { + Vue.config.warnHandler = jest.fn(); + }); + + afterEach(() => { + Vue.config.warnHandler = null; + }); + + it('throws a validation error', () => { + vm = mountComponent(Component, { + endDateString: 'this is invalid', + }); + + expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1); + const [errorMessage] = Vue.config.warnHandler.mock.calls[0]; + + expect(errorMessage).toMatch(/^Invalid prop: .* "endDateString"/); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js new file mode 100644 index 00000000000..216563165d6 --- /dev/null +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import headerCi from '~/vue_shared/components/header_ci_component.vue'; + +describe('Header CI Component', () => { + let HeaderCi; + let vm; + let props; + + beforeEach(() => { + HeaderCi = Vue.extend(headerCi); + props = { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + itemName: 'job', + itemId: 123, + time: '2017-05-08T14:57:39.781Z', + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + hasSidebarButton: true, + }; + }); + + afterEach(() => { + vm.$destroy(); + }); + + const findActionButtons = () => vm.$el.querySelector('.header-action-buttons'); + + describe('render', () => { + beforeEach(() => { + vm = mountComponent(HeaderCi, props); + }); + + it('should render status badge', () => { + expect(vm.$el.querySelector('.ci-failed')).toBeDefined(); + expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined(); + expect(vm.$el.querySelector('.ci-failed').getAttribute('href')).toEqual( + props.status.details_path, + ); + }); + + it('should render item name and id', () => { + expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123'); + }); + + it('should render timeago date', () => { + expect(vm.$el.querySelector('time')).toBeDefined(); + }); + + it('should render user icon and name', () => { + expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name); + }); + + it('should render sidebar toggle button', () => { + expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull(); + }); + + it('should not render header action buttons when empty', () => { + expect(findActionButtons()).toBeNull(); + }); + }); + + describe('slot', () => { + it('should render header action buttons', () => { + vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } }); + + const buttons = findActionButtons(); + + expect(buttons).not.toBeNull(); + expect(buttons.textContent).toEqual('Test Actions'); + }); + }); + + describe('shouldRenderTriggeredLabel', () => { + it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => { + vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false }); + + expect(vm.$el.textContent).toContain('created'); + expect(vm.$el.textContent).not.toContain('triggered'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js index 5e8b013d480..53a55dcd6bd 100644 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ b/spec/frontend/vue_shared/components/identicon_spec.js @@ -4,12 +4,17 @@ import IdenticonComponent from '~/vue_shared/components/identicon.vue'; describe('Identicon', () => { let wrapper; - const createComponent = () => { + const defaultProps = { + entityId: 1, + entityName: 'entity-name', + sizeClass: 's40', + }; + + const createComponent = (props = {}) => { wrapper = shallowMount(IdenticonComponent, { propsData: { - entityId: 1, - entityName: 'entity-name', - sizeClass: 's40', + ...defaultProps, + ...props, }, }); }; @@ -19,15 +24,27 @@ describe('Identicon', () => { wrapper = null; }); - it('matches snapshot', () => { - createComponent(); + describe('entity id is a number', () => { + beforeEach(createComponent); + + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - expect(wrapper.element).toMatchSnapshot(); + it('adds a correct class to identicon', () => { + expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); + }); }); - it('adds a correct class to identicon', () => { - createComponent(); + describe('entity id is a GraphQL id', () => { + beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' })); + + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); + it('adds a correct class to identicon', () => { + expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); + }); }); }); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index 4c654e01f74..90c3fe54901 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -36,9 +36,7 @@ describe('IssueMilestoneComponent', () => { describe('isMilestoneStarted', () => { it('should return `false` when milestoneStart prop is not defined', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '', - }), + milestone: { ...mockMilestone, start_date: '' }, }); expect(wrapper.vm.isMilestoneStarted).toBe(false); @@ -46,9 +44,7 @@ describe('IssueMilestoneComponent', () => { it('should return `true` when milestone start date is past current date', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - }), + milestone: { ...mockMilestone, start_date: '1990-07-22' }, }); expect(wrapper.vm.isMilestoneStarted).toBe(true); @@ -58,9 +54,7 @@ describe('IssueMilestoneComponent', () => { describe('isMilestonePastDue', () => { it('should return `false` when milestoneDue prop is not defined', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: '', - }), + milestone: { ...mockMilestone, due_date: '' }, }); expect(wrapper.vm.isMilestonePastDue).toBe(false); @@ -68,9 +62,7 @@ describe('IssueMilestoneComponent', () => { it('should return `true` when milestone due is past current date', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: '1990-07-22', - }), + milestone: { ...mockMilestone, due_date: '1990-07-22' }, }); expect(wrapper.vm.isMilestonePastDue).toBe(true); @@ -84,9 +76,7 @@ describe('IssueMilestoneComponent', () => { it('returns string containing absolute milestone start date when due date is not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: '', - }), + milestone: { ...mockMilestone, due_date: '' }, }); expect(wrapper.vm.milestoneDatesAbsolute).toBe('(January 1, 2018)'); @@ -94,10 +84,7 @@ describe('IssueMilestoneComponent', () => { it('returns empty string when both milestone start and due dates are not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), + milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); expect(wrapper.vm.milestoneDatesAbsolute).toBe(''); @@ -107,9 +94,7 @@ describe('IssueMilestoneComponent', () => { describe('milestoneDatesHuman', () => { it('returns string containing milestone due date when date is yet to be due', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - due_date: `${new Date().getFullYear() + 10}-01-01`, - }), + milestone: { ...mockMilestone, due_date: `${new Date().getFullYear() + 10}-01-01` }, }); expect(wrapper.vm.milestoneDatesHuman).toContain('years remaining'); @@ -117,10 +102,7 @@ describe('IssueMilestoneComponent', () => { it('returns string containing milestone start date when date has already started and due date is not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '1990-07-22', - due_date: '', - }), + milestone: { ...mockMilestone, start_date: '1990-07-22', due_date: '' }, }); expect(wrapper.vm.milestoneDatesHuman).toContain('Started'); @@ -128,10 +110,11 @@ describe('IssueMilestoneComponent', () => { it('returns string containing milestone start date when date is yet to start and due date is not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { + milestone: { + ...mockMilestone, start_date: `${new Date().getFullYear() + 10}-01-01`, due_date: '', - }), + }, }); expect(wrapper.vm.milestoneDatesHuman).toContain('Starts'); @@ -139,10 +122,7 @@ describe('IssueMilestoneComponent', () => { it('returns empty string when milestone start and due dates are not present', () => { wrapper.setProps({ - milestone: Object.assign({}, mockMilestone, { - start_date: '', - due_date: '', - }), + milestone: { ...mockMilestone, start_date: '', due_date: '' }, }); expect(wrapper.vm.milestoneDatesHuman).toBe(''); diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index f7b1f041ef2..dd24ecf707d 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -2,10 +2,7 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import { - defaultAssignees, - defaultMilestone, -} from '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data'; +import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; describe('RelatedIssuableItem', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 46e269e5071..54ce1f47e28 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -9,9 +9,9 @@ const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) { - expect(writeLink.element.parentNode.classList.contains('active')).toEqual(isWrite); - expect(previewLink.element.parentNode.classList.contains('active')).toEqual(!isWrite); - expect(wrapper.find('.md-preview-holder').element.style.display).toEqual(isWrite ? 'none' : ''); + expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite); + expect(previewLink.element.parentNode.classList.contains('active')).toBe(!isWrite); + expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : ''); } function createComponent() { @@ -67,6 +67,10 @@ describe('Markdown field component', () => { let previewLink; let writeLink; + afterEach(() => { + wrapper.destroy(); + }); + it('renders textarea inside backdrop', () => { wrapper = createComponent(); expect(wrapper.find('.zen-backdrop textarea').element).not.toBeNull(); @@ -92,32 +96,24 @@ describe('Markdown field component', () => { previewLink = getPreviewLink(wrapper); previewLink.trigger('click'); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain( 'Loading…', ); }); }); - it('renders markdown preview', () => { + it('renders markdown preview and GFM', () => { wrapper = createComponent(); - previewLink = getPreviewLink(wrapper); - previewLink.trigger('click'); + const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); - setTimeout(() => { - expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); - }); - }); - - it('renders GFM with jQuery', () => { - wrapper = createComponent(); previewLink = getPreviewLink(wrapper); - jest.spyOn($.fn, 'renderGFM'); previewLink.trigger('click'); return axios.waitFor(markdownPreviewPath).then(() => { expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); + expect(renderGFMSpy).toHaveBeenCalled(); }); }); @@ -176,7 +172,7 @@ describe('Markdown field component', () => { const markdownButton = getMarkdownButton(wrapper); markdownButton.trigger('click'); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('**testing**'); }); }); @@ -188,8 +184,8 @@ describe('Markdown field component', () => { const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; markdownButton.trigger('click'); - wrapper.vm.$nextTick(() => { - expect(textarea.value).toContain('* testing'); + return wrapper.vm.$nextTick(() => { + expect(textarea.value).toContain('* testing'); }); }); @@ -200,7 +196,7 @@ describe('Markdown field component', () => { const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5]; markdownButton.trigger('click'); - wrapper.vm.$nextTick(() => { + return wrapper.vm.$nextTick(() => { expect(textarea.value).toContain('* testing\n* 123'); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js new file mode 100644 index 00000000000..80cf1f655c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js @@ -0,0 +1,26 @@ +import $ from 'jquery'; +import { shallowMount } from '@vue/test-utils'; + +import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; + +describe('Markdown Field View component', () => { + let renderGFMSpy; + let wrapper; + + function createComponent() { + wrapper = shallowMount(MarkdownFieldView); + } + + beforeEach(() => { + renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('processes rendering with GFM', () => { + expect(renderGFMSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js new file mode 100644 index 00000000000..34ccdf38b00 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js @@ -0,0 +1,102 @@ +import Vue from 'vue'; +import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue'; + +const MOCK_DATA = { + suggestions: [ + { + id: 1, + appliable: true, + applied: false, + current_user: { + can_apply: true, + }, + diff_lines: [ + { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: null, + old_line: 5, + rich_text: '-test', + text: '-test', + type: 'old', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test', + text: '+new test', + type: 'new', + }, + ], + }, + ], + noteHtml: ` + <div class="suggestion"> + <div class="line">-oldtest</div> + </div> + <div class="suggestion"> + <div class="line">+newtest</div> + </div> + `, + isApplied: false, + helpPagePath: 'path_to_docs', +}; + +describe('Suggestion component', () => { + let vm; + let diffTable; + + beforeEach(done => { + const Component = Vue.extend(SuggestionsComponent); + + vm = new Component({ + propsData: MOCK_DATA, + }).$mount(); + + diffTable = vm.generateDiff(0).$mount().$el; + + jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {}); + vm.renderSuggestions(); + Vue.nextTick(done); + }); + + describe('mounted', () => { + it('renders a flash container', () => { + expect(vm.$el.querySelector('.js-suggestions-flash')).not.toBeNull(); + }); + + it('renders a container for suggestions', () => { + expect(vm.$refs.container).not.toBeNull(); + }); + + it('renders suggestions', () => { + expect(vm.renderSuggestions).toHaveBeenCalled(); + expect(vm.$el.innerHTML.includes('oldtest')).toBe(true); + expect(vm.$el.innerHTML.includes('newtest')).toBe(true); + }); + }); + + describe('generateDiff', () => { + it('generates a diff table', () => { + expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull(); + }); + + it('generates a diff table that contains contents the suggested lines', () => { + MOCK_DATA.suggestions[0].diff_lines.forEach(line => { + const text = line.text.substring(1); + + expect(diffTable.innerHTML.includes(text)).toBe(true); + }); + }); + + it('generates a diff table with the correct line number for each suggested line', () => { + const lines = diffTable.querySelectorAll('.old_line'); + + expect(parseInt([...lines][0].innerHTML, 10)).toBe(5); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js new file mode 100644 index 00000000000..e7c31014bfc --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; + +describe('toolbar', () => { + let vm; + const Toolbar = Vue.extend(toolbar); + const props = { + markdownDocsPath: '', + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('user can attach file', () => { + beforeEach(() => { + vm = mountComponent(Toolbar, props); + }); + + it('should render uploading-container', () => { + expect(vm.$el.querySelector('.uploading-container')).not.toBeNull(); + }); + }); + + describe('user cannot attach file', () => { + beforeEach(() => { + vm = mountComponent(Toolbar, { ...props, canAttachFile: false }); + }); + + it('should not render uploading-container', () => { + expect(vm.$el.querySelector('.uploading-container')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/navigation_tabs_spec.js b/spec/frontend/vue_shared/components/navigation_tabs_spec.js new file mode 100644 index 00000000000..561456d614e --- /dev/null +++ b/spec/frontend/vue_shared/components/navigation_tabs_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import navigationTabs from '~/vue_shared/components/navigation_tabs.vue'; + +describe('navigation tabs component', () => { + let vm; + let Component; + let data; + + beforeEach(() => { + data = [ + { + name: 'All', + scope: 'all', + count: 1, + isActive: true, + }, + { + name: 'Pending', + scope: 'pending', + count: 0, + isActive: false, + }, + { + name: 'Running', + scope: 'running', + isActive: false, + }, + ]; + + Component = Vue.extend(navigationTabs); + vm = mountComponent(Component, { tabs: data, scope: 'pipelines' }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render tabs', () => { + expect(vm.$el.querySelectorAll('li').length).toEqual(data.length); + }); + + it('should render active tab', () => { + expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined(); + }); + + it('should render badge', () => { + expect(vm.$el.querySelector('.js-pipelines-tab-all .badge').textContent.trim()).toEqual('1'); + expect(vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim()).toEqual( + '0', + ); + }); + + it('should not render badge', () => { + expect(vm.$el.querySelector('.js-pipelines-tab-running .badge')).toEqual(null); + }); + + it('should trigger onTabClick', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-pipelines-tab-pending').click(); + + expect(vm.$emit).toHaveBeenCalledWith('onChangeTab', 'pending'); + }); +}); diff --git a/spec/frontend/vue_shared/components/pikaday_spec.js b/spec/frontend/vue_shared/components/pikaday_spec.js new file mode 100644 index 00000000000..867bf88ff50 --- /dev/null +++ b/spec/frontend/vue_shared/components/pikaday_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import datePicker from '~/vue_shared/components/pikaday.vue'; + +describe('datePicker', () => { + let vm; + beforeEach(() => { + const DatePicker = Vue.extend(datePicker); + vm = mountComponent(DatePicker, { + label: 'label', + }); + }); + + it('should render label text', () => { + expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label'); + }); + + it('should show calendar', () => { + expect(vm.$el.querySelector('.pika-single')).toBeDefined(); + }); + + it('should toggle when dropdown is clicked', () => { + const hidePicker = jest.fn(); + vm.$on('hidePicker', hidePicker); + + vm.$el.querySelector('.dropdown-menu-toggle').click(); + + expect(hidePicker).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js new file mode 100644 index 00000000000..090f8b69213 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_avatar/default_spec.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import { projectData } from 'jest/ide/mock_data'; +import { TEST_HOST } from 'spec/test_constants'; +import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; +import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; + +describe('ProjectAvatarDefault component', () => { + const Component = Vue.extend(ProjectAvatarDefault); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + project: projectData, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders identicon if project has no avatar_url', done => { + const expectedText = getFirstCharacterCapitalized(projectData.name); + + vm.project = { + ...vm.project, + avatar_url: null, + }; + + vm.$nextTick() + .then(() => { + const identiconEl = vm.$el.querySelector('.identicon'); + + expect(identiconEl).not.toBe(null); + expect(identiconEl.textContent.trim()).toEqual(expectedText); + }) + .then(done) + .catch(done.fail); + }); + + it('renders avatar image if project has avatar_url', done => { + const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; + + vm.project = { + ...vm.project, + avatar_url: avatarUrl, + }; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.avatar')).not.toBeNull(); + expect(vm.$el.querySelector('.identicon')).toBeNull(); + expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js new file mode 100644 index 00000000000..eb1d9e93634 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -0,0 +1,109 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; + +const localVue = createLocalVue(); + +describe('ProjectListItem component', () => { + const Component = localVue.extend(ProjectListItem); + let wrapper; + let vm; + let options; + + const project = getJSONFixture('static/projects.json')[0]; + + beforeEach(() => { + options = { + propsData: { + project, + selected: false, + }, + localVue, + }; + }); + + afterEach(() => { + wrapper.vm.$destroy(); + }); + + it('does not render a check mark icon if selected === false', () => { + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true); + }); + + it('renders a check mark icon if selected === true', () => { + options.propsData.selected = true; + + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true); + }); + + it(`emits a "clicked" event when clicked`, () => { + wrapper = shallowMount(Component, options); + ({ vm } = wrapper); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.vm.onClick(); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + }); + + it(`renders the project avatar`, () => { + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-project-avatar')).toBe(true); + }); + + it(`renders a simple namespace name with a trailing slash`, () => { + options.propsData.project.name_with_namespace = 'a / b'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('a /'); + }); + + it(`renders a properly truncated namespace with a trailing slash`, () => { + options.propsData.project.name_with_namespace = 'a / b / c / d / e / f'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('a / ... / e /'); + }); + + it(`renders the project name`, () => { + options.propsData.project.name = 'my-test-project'; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').text()); + + expect(renderedName).toBe('my-test-project'); + }); + + it(`renders the project name with highlighting in the case of a search query match`, () => { + options.propsData.project.name = 'my-test-project'; + options.propsData.matcher = 'pro'; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').html()); + const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject'; + + expect(renderedName).toContain(expected); + }); + + it('prevents search query and project name XSS', () => { + const alertSpy = jest.spyOn(window, 'alert'); + options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject"; + options.propsData.matcher = "pro<script>alert('XSS');</script>"; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').html()); + const expected = 'my-xss-project'; + + expect(renderedName).toContain(expected); + expect(alertSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js new file mode 100644 index 00000000000..29bced394dc --- /dev/null +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import { head } from 'lodash'; + +import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; +import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; + +const localVue = createLocalVue(); + +describe('ProjectSelector component', () => { + let wrapper; + let vm; + const allProjects = getJSONFixture('static/projects.json'); + const searchResults = allProjects.slice(0, 5); + let selected = []; + selected = selected.concat(allProjects.slice(0, 3)).concat(allProjects.slice(5, 8)); + + const findSearchInput = () => wrapper.find(GlSearchBoxByType).find('input'); + + beforeEach(() => { + wrapper = mount(Vue.extend(ProjectSelector), { + localVue, + propsData: { + projectSearchResults: searchResults, + selectedProjects: selected, + showNoResultsMessage: false, + showMinimumSearchQueryMessage: false, + showLoadingIndicator: false, + showSearchErrorMessage: false, + }, + attachToDocument: true, + }); + + ({ vm } = wrapper); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders the search results', () => { + expect(wrapper.findAll('.js-project-list-item').length).toBe(5); + }); + + it(`triggers a search when the search input value changes`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + const query = 'my test query!'; + const searchInput = findSearchInput(); + + searchInput.setValue(query); + searchInput.trigger('input'); + + expect(vm.$emit).toHaveBeenCalledWith('searched', query); + }); + + it(`includes a placeholder in the search box`, () => { + const searchInput = findSearchInput(); + + expect(searchInput.attributes('placeholder')).toBe('Search your projects'); + }); + + it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached'); + + expect(vm.$emit).toHaveBeenCalledWith('bottomReached'); + }); + + it(`triggers a "projectClicked" event when a project is clicked`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.find(ProjectListItem).vm.$emit('click', head(searchResults)); + + expect(vm.$emit).toHaveBeenCalledWith('projectClicked', head(searchResults)); + }); + + it(`shows a "no results" message if showNoResultsMessage === true`, () => { + wrapper.setProps({ showNoResultsMessage: true }); + + return vm.$nextTick().then(() => { + const noResultsEl = wrapper.find('.js-no-results-message'); + + expect(noResultsEl.exists()).toBe(true); + expect(trimText(noResultsEl.text())).toEqual('Sorry, no projects matched your search'); + }); + }); + + it(`shows a "minimum search query" message if showMinimumSearchQueryMessage === true`, () => { + wrapper.setProps({ showMinimumSearchQueryMessage: true }); + + return vm.$nextTick().then(() => { + const minimumSearchEl = wrapper.find('.js-minimum-search-query-message'); + + expect(minimumSearchEl.exists()).toBe(true); + expect(trimText(minimumSearchEl.text())).toEqual('Enter at least three characters to search'); + }); + }); + + it(`shows a error message if showSearchErrorMessage === true`, () => { + wrapper.setProps({ showSearchErrorMessage: true }); + + return vm.$nextTick().then(() => { + const errorMessageEl = wrapper.find('.js-search-error-message'); + + expect(errorMessageEl.exists()).toBe(true); + expect(trimText(errorMessageEl.text())).toEqual( + 'Something went wrong, unable to search projects', + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js new file mode 100644 index 00000000000..549d89171c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; +import { + EDITOR_OPTIONS, + EDITOR_TYPES, + EDITOR_HEIGHT, + EDITOR_PREVIEW_STYLE, +} from '~/vue_shared/components/rich_content_editor/constants'; + +describe('Rich Content Editor', () => { + let wrapper; + + const value = '## Some Markdown'; + const findEditor = () => wrapper.find({ ref: 'editor' }); + + beforeEach(() => { + wrapper = shallowMount(RichContentEditor, { + propsData: { value }, + }); + }); + + describe('when content is loaded', () => { + it('renders an editor', () => { + expect(findEditor().exists()).toBe(true); + }); + + it('renders the correct content', () => { + expect(findEditor().props().initialValue).toBe(value); + }); + + it('provides the correct editor options', () => { + expect(findEditor().props().options).toEqual(EDITOR_OPTIONS); + }); + + it('has the correct preview style', () => { + expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE); + }); + + it('has the correct initial edit type', () => { + expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg); + }); + + it('has the correct height', () => { + expect(findEditor().props().height).toBe(EDITOR_HEIGHT); + }); + }); + + describe('when content is changed', () => { + it('emits an input event with the changed content', () => { + const changedMarkdown = '## Changed Markdown'; + const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown); + + findEditor().setMethods({ invoke: getMarkdownMock }); + findEditor().vm.$emit('change'); + + expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js new file mode 100644 index 00000000000..8545c43dc1e --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue'; + +describe('Toolbar Item', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findButton = () => wrapper.find('button'); + + const buildWrapper = propsData => { + wrapper = shallowMount(ToolbarItem, { propsData }); + }; + + describe.each` + icon + ${'heading'} + ${'bold'} + ${'italic'} + ${'strikethrough'} + ${'quote'} + ${'link'} + ${'doc-code'} + ${'list-bulleted'} + ${'list-numbered'} + ${'list-task'} + ${'list-indent'} + ${'list-outdent'} + ${'dash'} + ${'table'} + ${'code'} + `('toolbar item component', ({ icon }) => { + beforeEach(() => buildWrapper({ icon })); + + it('renders a toolbar button', () => { + expect(findButton().exists()).toBe(true); + }); + + it(`renders the ${icon} icon`, () => { + expect(findIcon().exists()).toBe(true); + expect(findIcon().props().name).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js new file mode 100644 index 00000000000..7605cc6a22c --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_service_spec.js @@ -0,0 +1,29 @@ +import { generateToolbarItem } from '~/vue_shared/components/rich_content_editor/toolbar_service'; + +describe('Toolbar Service', () => { + const config = { + icon: 'bold', + command: 'some-command', + tooltip: 'Some Tooltip', + event: 'some-event', + }; + const generatedItem = generateToolbarItem(config); + + it('generates the correct command', () => { + expect(generatedItem.options.command).toBe(config.command); + }); + + it('generates the correct tooltip', () => { + expect(generatedItem.options.tooltip).toBe(config.tooltip); + }); + + it('generates the correct event', () => { + expect(generatedItem.options.event).toBe(config.event); + }); + + it('generates a divider when isDivider is set to true', () => { + const isDivider = true; + + expect(generateToolbarItem({ isDivider })).toBe('divider'); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js index d90fafb6bf7..9db86fa775f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js @@ -4,10 +4,7 @@ import { shallowMount } from '@vue/test-utils'; import LabelsSelect from '~/labels_select'; import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = (config = mockConfig) => shallowMount(BaseComponent, { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index e2e11c94c0d..d02d924bd2b 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -3,16 +3,14 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; -const componentConfig = Object.assign({}, mockConfig, { +const componentConfig = { + ...mockConfig, fieldName: 'label_id[]', labels: mockLabels, showExtraOptions: false, -}); +}; const createComponent = (config = componentConfig) => { const Component = Vue.extend(dropdownButtonComponent); @@ -34,7 +32,7 @@ describe('DropdownButtonComponent', () => { describe('computed', () => { describe('dropdownToggleText', () => { it('returns text as `Label` when `labels` prop is empty array', () => { - const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] }); + const mockEmptyLabels = { ...componentConfig, labels: [] }; const vmEmptyLabels = createComponent(mockEmptyLabels); expect(vmEmptyLabels.dropdownToggleText).toBe('Label'); @@ -42,9 +40,7 @@ describe('DropdownButtonComponent', () => { }); it('returns first label name with remaining label count when `labels` prop has more than one item', () => { - const mockMoreLabels = Object.assign({}, componentConfig, { - labels: mockLabels.concat(mockLabels), - }); + const mockMoreLabels = { ...componentConfig, labels: mockLabels.concat(mockLabels) }; const vmMoreLabels = createComponent(mockMoreLabels); expect(vmMoreLabels.dropdownToggleText).toBe( @@ -54,9 +50,7 @@ describe('DropdownButtonComponent', () => { }); it('returns first label name when `labels` prop has only one item present', () => { - const singleLabel = Object.assign({}, componentConfig, { - labels: [mockLabels[0]], - }); + const singleLabel = { ...componentConfig, labels: [mockLabels[0]] }; const vmSingleLabel = createComponent(singleLabel); expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index d0299523137..edec3b138b3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; -import { mockSuggestedColors } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockSuggestedColors } from './mock_data'; const createComponent = headerTitle => { const Component = Vue.extend(dropdownCreateLabelComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js index 784bbaf8e6a..7e9e242a4f5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; -import { mockConfig } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig } from './mock_data'; const createComponent = ( labelsWebUrl = mockConfig.labelsWebUrl, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 887c04268d1..e09f0006359 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; -import { mockLabels } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 06355c0dd65..c33cffb421d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -2,10 +2,7 @@ import { mount } from '@vue/test-utils'; import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; import { GlLabel } from '@gitlab/ui'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = ( labels = mockLabels, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js new file mode 100644 index 00000000000..6564c012e67 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js @@ -0,0 +1,57 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#FFFFFF', + }, +]; + +export const mockSuggestedColors = [ + '#0033CC', + '#428BCA', + '#44AD8E', + '#A8D695', + '#5CB85C', + '#69D100', + '#004E00', + '#34495E', + '#7F8C8D', + '#A295D6', + '#5843AD', + '#8E44AD', + '#FFECDB', + '#AD4363', + '#D10069', + '#CC0033', + '#FF0000', + '#D9534F', + '#D1D100', + '#F0AD4E', + '#AD8D43', +]; + +export const mockConfig = { + showCreate: true, + isProject: true, + abilityName: 'issue', + context: { + labels: mockLabels, + }, + namespace: 'gitlab-org', + updatePath: '/gitlab-org/my-project/issue/1', + labelsPath: '/gitlab-org/my-project/-/labels.json', + labelsWebUrl: '/gitlab-org/my-project/-/labels', + labelFilterBasePath: '/gitlab-org/my-project/issues', + canEdit: true, + suggestedColors: mockSuggestedColors, + emptyValueText: 'None', +}; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js index e2d31a41e82..214eb239432 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js @@ -33,9 +33,32 @@ describe('DropdownButton', () => { wrapper.destroy(); }); + describe('methods', () => { + describe('handleButtonClick', () => { + it('calls action `toggleDropdownContents` and stops event propagation when `state.variant` is "standalone"', () => { + const event = { + stopPropagation: jest.fn(), + }; + wrapper = createComponent({ + ...mockConfig, + variant: 'standalone', + }); + + jest.spyOn(wrapper.vm, 'toggleDropdownContents'); + + wrapper.vm.handleButtonClick(event); + + expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + + wrapper.destroy(); + }); + }); + }); + describe('template', () => { it('renders component container element', () => { - expect(wrapper.is('gl-deprecated-button-stub')).toBe(true); + expect(wrapper.is('gl-button-stub')).toBe(true); }); it('renders button text element', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js index d7ca7ce30a9..04320a72be6 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedButton, GlIcon, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue'; import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; @@ -127,12 +127,12 @@ describe('DropdownContentsCreateView', () => { it('renders dropdown back button element', () => { const backBtnEl = wrapper .find('.dropdown-title') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(0); expect(backBtnEl.exists()).toBe(true); expect(backBtnEl.attributes('aria-label')).toBe('Go back'); - expect(backBtnEl.find(GlIcon).props('name')).toBe('arrow-left'); + expect(backBtnEl.props('icon')).toBe('arrow-left'); }); it('renders dropdown title element', () => { @@ -145,12 +145,12 @@ describe('DropdownContentsCreateView', () => { it('renders dropdown close button element', () => { const closeBtnEl = wrapper .find('.dropdown-title') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(1); expect(closeBtnEl.exists()).toBe(true); expect(closeBtnEl.attributes('aria-label')).toBe('Close'); - expect(closeBtnEl.find(GlIcon).props('name')).toBe('close'); + expect(closeBtnEl.props('icon')).toBe('close'); }); it('renders label title input element', () => { @@ -192,7 +192,7 @@ describe('DropdownContentsCreateView', () => { it('renders create button element', () => { const createBtnEl = wrapper .find('.dropdown-actions') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(0); expect(createBtnEl.exists()).toBe(true); @@ -213,7 +213,7 @@ describe('DropdownContentsCreateView', () => { it('renders cancel button element', () => { const cancelBtnEl = wrapper .find('.dropdown-actions') - .findAll(GlDeprecatedButton) + .findAll(GlButton) .at(1); expect(cancelBtnEl.exists()).toBe(true); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 3e6dbdb7ecb..74c769f86a3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -1,9 +1,10 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedButton, GlLoadingIcon, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; @@ -41,13 +42,19 @@ const createComponent = (initialState = mockConfig) => { describe('DropdownContentsLabelsView', () => { let wrapper; + let wrapperStandalone; beforeEach(() => { wrapper = createComponent(); + wrapperStandalone = createComponent({ + ...mockConfig, + variant: 'standalone', + }); }); afterEach(() => { wrapper.destroy(); + wrapperStandalone.destroy(); }); describe('computed', () => { @@ -72,16 +79,6 @@ describe('DropdownContentsLabelsView', () => { }); describe('methods', () => { - describe('getDropdownLabelBoxStyle', () => { - it('returns an object containing `backgroundColor` based on provided `label` param', () => { - expect(wrapper.vm.getDropdownLabelBoxStyle(mockRegularLabel)).toEqual( - expect.objectContaining({ - backgroundColor: mockRegularLabel.color, - }), - ); - }); - }); - describe('isLabelSelected', () => { it('returns true when provided `label` param is one of the selected labels', () => { expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true); @@ -165,13 +162,24 @@ describe('DropdownContentsLabelsView', () => { }); describe('handleLabelClick', () => { - it('calls action `updateSelectedLabels` with provided `label` param', () => { + beforeEach(() => { jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation(); + }); + it('calls action `updateSelectedLabels` with provided `label` param', () => { wrapper.vm.handleLabelClick(mockRegularLabel); expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]); }); + + it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => { + jest.spyOn(wrapper.vm, 'toggleDropdownContents'); + wrapper.vm.$store.state.allowMultiselect = false; + + wrapper.vm.handleLabelClick(mockRegularLabel); + + expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled(); + }); }); }); @@ -198,12 +206,15 @@ describe('DropdownContentsLabelsView', () => { expect(titleEl.text()).toBe('Assign labels'); }); + it('does not render dropdown title element when `state.variant` is "standalone"', () => { + expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false); + }); + it('renders dropdown close button element', () => { - const closeButtonEl = wrapper.find('.dropdown-title').find(GlDeprecatedButton); + const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); expect(closeButtonEl.exists()).toBe(true); - expect(closeButtonEl.find(GlIcon).exists()).toBe(true); - expect(closeButtonEl.find(GlIcon).props('name')).toBe('close'); + expect(closeButtonEl.props('icon')).toBe('close'); }); it('renders label search input element', () => { @@ -214,16 +225,7 @@ describe('DropdownContentsLabelsView', () => { }); it('renders label elements for all labels', () => { - const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(GlLink); - - expect(labelsEl.length).toBe(mockLabels.length); - expect(labelItemEl.exists()).toBe(true); - expect(labelItemEl.find(GlIcon).props('name')).toBe('mobile-issue-close'); - expect(labelItemEl.find('.dropdown-label-box').attributes('style')).toBe( - 'background-color: rgb(186, 218, 85);', - ); - expect(labelItemEl.find(GlLink).text()).toContain(mockLabels[0].title); + expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); }); it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { @@ -233,9 +235,9 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(GlLink); + const labelItemEl = labelsEl.at(0).find(LabelItem); - expect(labelItemEl.attributes('class')).toContain('is-focused'); + expect(labelItemEl.props('highlight')).toBe(true); }); }); @@ -247,19 +249,42 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const noMatchEl = wrapper.find('.dropdown-content li'); - expect(noMatchEl.exists()).toBe(true); + expect(noMatchEl.isVisible()).toBe(true); expect(noMatchEl.text()).toContain('No matching results'); }); }); it('renders footer list items', () => { - const createLabelBtn = wrapper.find('.dropdown-footer').find(GlDeprecatedButton); - const manageLabelsLink = wrapper.find('.dropdown-footer').find(GlLink); - - expect(createLabelBtn.exists()).toBe(true); - expect(createLabelBtn.text()).toBe('Create label'); + const createLabelLink = wrapper + .find('.dropdown-footer') + .findAll(GlLink) + .at(0); + const manageLabelsLink = wrapper + .find('.dropdown-footer') + .findAll(GlLink) + .at(1); + + expect(createLabelLink.exists()).toBe(true); + expect(createLabelLink.text()).toBe('Create label'); expect(manageLabelsLink.exists()).toBe(true); expect(manageLabelsLink.text()).toBe('Manage labels'); }); + + it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => { + wrapper.vm.$store.state.allowLabelCreate = false; + + return wrapper.vm.$nextTick(() => { + const createLabelLink = wrapper + .find('.dropdown-footer') + .findAll(GlLink) + .at(0); + + expect(createLabelLink.text()).not.toBe('Create label'); + }); + }); + + it('does not render footer list items when `state.variant` is "standalone"', () => { + expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js new file mode 100644 index 00000000000..401d208da5c --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js @@ -0,0 +1,111 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlIcon, GlLink } from '@gitlab/ui'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; +import { mockRegularLabel } from './mock_data'; + +const createComponent = ({ label = mockRegularLabel, highlight = true } = {}) => + shallowMount(LabelItem, { + propsData: { + label, + highlight, + }, + }); + +describe('LabelItem', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('labelBoxStyle', () => { + it('returns an object containing `backgroundColor` based on `label` prop', () => { + expect(wrapper.vm.labelBoxStyle).toEqual( + expect.objectContaining({ + backgroundColor: mockRegularLabel.color, + }), + ); + }); + }); + }); + + describe('methods', () => { + describe('handleClick', () => { + it('sets value of `isSet` data prop to opposite of its current value', () => { + wrapper.setData({ + isSet: true, + }); + + wrapper.vm.handleClick(); + expect(wrapper.vm.isSet).toBe(false); + wrapper.vm.handleClick(); + expect(wrapper.vm.isSet).toBe(true); + }); + + it('emits event `clickLabel` on component with `label` prop as param', () => { + wrapper.vm.handleClick(); + + expect(wrapper.emitted('clickLabel')).toBeTruthy(); + expect(wrapper.emitted('clickLabel')[0]).toEqual([mockRegularLabel]); + }); + }); + }); + + describe('template', () => { + it('renders gl-link component', () => { + expect(wrapper.find(GlLink).exists()).toBe(true); + }); + + it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => { + wrapper.setProps({ + highlight: true, + }); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.find(GlLink).classes()).toContain('is-focused'); + }); + }); + + it('renders visible gl-icon component when `isSet` prop is true', () => { + wrapper.setData({ + isSet: true, + }); + + return wrapper.vm.$nextTick(() => { + const iconEl = wrapper.find(GlIcon); + + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe('mobile-issue-close'); + }); + }); + + it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => { + wrapper.setData({ + isSet: false, + }); + + return wrapper.vm.$nextTick(() => { + const placeholderEl = wrapper.find('[data-testid="no-icon"]'); + + expect(placeholderEl.isVisible()).toBe(true); + }); + }); + + it('renders label color element', () => { + const colorEl = wrapper.find('[data-testid="label-color-box"]'); + + expect(colorEl.exists()).toBe(true); + expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);'); + }); + + it('renders label title', () => { + expect(wrapper.text()).toContain(mockRegularLabel.title); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 126fd5438c4..ee4e9090e5d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -89,6 +89,19 @@ describe('LabelsSelectRoot', () => { expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative'); }); + it('renders component root element with CSS class `is-standalone` when `state.variant` is "standalone"', () => { + const wrapperStandalone = createComponent({ + ...mockConfig, + variant: 'standalone', + }); + + return wrapperStandalone.vm.$nextTick(() => { + expect(wrapperStandalone.classes()).toContain('is-standalone'); + + wrapperStandalone.destroy(); + }); + }); + it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', () => { expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); }); @@ -101,13 +114,16 @@ describe('LabelsSelectRoot', () => { const wrapperDropdownValue = createComponent(mockConfig, { default: 'None', }); + wrapperDropdownValue.vm.$store.state.showDropdownButton = false; - const valueComp = wrapperDropdownValue.find(DropdownValue); + return wrapperDropdownValue.vm.$nextTick(() => { + const valueComp = wrapperDropdownValue.find(DropdownValue); - expect(valueComp.exists()).toBe(true); - expect(valueComp.text()).toBe('None'); + expect(valueComp.exists()).toBe(true); + expect(valueComp.text()).toBe('None'); - wrapperDropdownValue.destroy(); + wrapperDropdownValue.destroy(); + }); }); it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index a863cddbaee..e1008d13fc2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -30,15 +30,16 @@ export const mockConfig = { allowLabelEdit: true, allowLabelCreate: true, allowScopedLabels: true, + allowMultiselect: true, labelsListTitle: 'Assign labels', labelsCreateTitle: 'Create label', + variant: 'sidebar', dropdownOnly: false, selectedLabels: [mockRegularLabel, mockScopedLabel], labelsSelectInProgress: false, labelsFetchPath: '/gitlab-org/my-project/-/labels.json', labelsManagePath: '/gitlab-org/my-project/-/labels', labelsFilterBasePath: '/gitlab-org/my-project/issues', - scopedLabelsDocumentationPath: '/help/user/project/labels.md#scoped-labels-premium', }; export const mockSuggestedColors = { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index 6e2363ba96f..072d8fe2fe2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -15,7 +15,7 @@ describe('LabelsSelect Actions', () => { }; beforeEach(() => { - state = Object.assign({}, defaultState()); + state = { ...defaultState() }; }); describe('setInitialState', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js index bfceaa0828b..b866117efcf 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js @@ -5,19 +5,25 @@ describe('LabelsSelect Getters', () => { it('returns string "Label" when state.labels has no selected labels', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - expect(getters.dropdownButtonText({ labels })).toBe('Label'); + expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( + 'Label', + ); }); it('returns label title when state.labels has only 1 label', () => { const labels = [{ id: 1, title: 'Foobar', set: true }]; - expect(getters.dropdownButtonText({ labels })).toBe('Foobar'); + expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( + 'Foobar', + ); }); it('returns first label title and remaining labels count when state.labels has more than 1 label', () => { const labels = [{ id: 1, title: 'Foo', set: true }, { id: 2, title: 'Bar', set: true }]; - expect(getters.dropdownButtonText({ labels })).toBe('Foo +1 more'); + expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe( + 'Foo +1 more', + ); }); }); @@ -28,4 +34,16 @@ describe('LabelsSelect Getters', () => { expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]); }); }); + + describe('isDropdownVariantSidebar', () => { + it('returns `true` when `state.variant` is "sidebar"', () => { + expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true); + }); + }); + + describe('isDropdownVariantStandalone', () => { + it('returns `true` when `state.variant` is "standalone"', () => { + expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index f6ca98fcc71..8081806e314 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -29,6 +29,7 @@ describe('LabelsSelect Mutations', () => { const state = { dropdownOnly: false, showDropdownButton: false, + variant: 'sidebar', }; mutations[types.TOGGLE_DROPDOWN_CONTENTS](state); @@ -155,11 +156,11 @@ describe('LabelsSelect Mutations', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => { - const updatedLabelIds = [2, 4]; + const updatedLabelIds = [2]; const state = { labels, }; - mutations[types.UPDATE_SELECTED_LABELS](state, { labels }); + mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] }); state.labels.forEach(label => { if (updatedLabelIds.includes(label.id)) { diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js new file mode 100644 index 00000000000..bc86ee5a0c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -0,0 +1,104 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; + +const createComponent = config => { + const Component = Vue.extend(stackedProgressBarComponent); + const defaultConfig = { + successLabel: 'Synced', + failureLabel: 'Failed', + neutralLabel: 'Out of sync', + successCount: 25, + failureCount: 10, + totalCount: 5000, + ...config, + }; + + return mountComponent(Component, defaultConfig); +}; + +describe('StackedProgressBarComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('neutralCount', () => { + it('returns neutralCount based on totalCount, successCount and failureCount', () => { + expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10 + }); + }); + }); + + describe('methods', () => { + describe('getPercent', () => { + it('returns percentage from provided count based on `totalCount`', () => { + expect(vm.getPercent(500)).toBe(10); + }); + + it('returns percentage with decimal place from provided count based on `totalCount`', () => { + expect(vm.getPercent(67)).toBe(1.3); + }); + + it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => { + expect(vm.getPercent(10)).toBe('< 1'); + }); + + it('returns 0 if totalCount is falsy', () => { + vm = createComponent({ totalCount: 0 }); + + expect(vm.getPercent(100)).toBe(0); + }); + }); + + describe('barStyle', () => { + it('returns style string based on percentage provided', () => { + expect(vm.barStyle(50)).toBe('width: 50%;'); + }); + }); + + describe('getTooltip', () => { + describe('when hideTooltips is false', () => { + it('returns label string based on label and count provided', () => { + expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10'); + }); + }); + + describe('when hideTooltips is true', () => { + beforeEach(() => { + vm = createComponent({ hideTooltips: true }); + }); + + it('returns an empty string', () => { + expect(vm.getTooltip('Synced', 10)).toBe(''); + }); + }); + }); + }); + + describe('template', () => { + it('renders container element', () => { + expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + }); + + it('renders empty state when count is unavailable', () => { + const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + + expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0); + vmX.$destroy(); + }); + + it('renders bar elements when count is available', () => { + expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0); + expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0); + expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js new file mode 100644 index 00000000000..8cf07a9177c --- /dev/null +++ b/spec/frontend/vue_shared/components/tabs/tab_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; + +describe('Tab component', () => { + const Component = Vue.extend(Tab); + let vm; + + beforeEach(() => { + vm = mountComponent(Component); + }); + + it('sets localActive to equal active', done => { + vm.active = true; + + vm.$nextTick(() => { + expect(vm.localActive).toBe(true); + + done(); + }); + }); + + it('sets active class', done => { + vm.active = true; + + vm.$nextTick(() => { + expect(vm.$el.classList).toContain('active'); + + done(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js new file mode 100644 index 00000000000..49d92094b34 --- /dev/null +++ b/spec/frontend/vue_shared/components/tabs/tabs_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import Tabs from '~/vue_shared/components/tabs/tabs'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; + +describe('Tabs component', () => { + let vm; + + beforeEach(() => { + vm = new Vue({ + components: { + Tabs, + Tab, + }, + render(h) { + return h('div', [ + h('tabs', [ + h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'), + h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']), + ]), + ]); + }, + }).$mount(); + + return vm.$nextTick(); + }); + + describe('tab links', () => { + it('renders links for tabs', () => { + expect(vm.$el.querySelectorAll('a').length).toBe(2); + }); + + it('renders link titles from props', () => { + expect(vm.$el.querySelector('a').textContent).toContain('Testing'); + }); + + it('renders link titles from slot', () => { + expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot'); + }); + + it('renders active class', () => { + expect(vm.$el.querySelector('a').classList).toContain('active'); + }); + + it('updates active class on click', () => { + vm.$el.querySelectorAll('a')[1].click(); + + return vm.$nextTick(() => { + expect(vm.$el.querySelector('a').classList).not.toContain('active'); + expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active'); + }); + }); + }); + + describe('content', () => { + it('renders content panes', () => { + expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2); + expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab'); + expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js new file mode 100644 index 00000000000..83bbb37a89a --- /dev/null +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toggleButton from '~/vue_shared/components/toggle_button.vue'; + +describe('Toggle Button', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(toggleButton); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('render output', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + name: 'foo', + }); + }); + + it('renders input with provided name', () => { + expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo'); + }); + + it('renders input with provided value', () => { + expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); + }); + + it('renders input status icon', () => { + expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); + expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1); + }); + }); + + describe('is-checked', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + }); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('renders is checked class', () => { + expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); + }); + + it('sets aria-label representing toggle state', () => { + vm.value = true; + + expect(vm.ariaLabel).toEqual('Toggle Status: ON'); + + vm.value = false; + + expect(vm.ariaLabel).toEqual('Toggle Status: OFF'); + }); + + it('emits change event when clicked', () => { + vm.$el.querySelector('button').click(); + + expect(vm.$emit).toHaveBeenCalledWith('change', false); + }); + }); + + describe('is-disabled', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + disabledInput: true, + }); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('renders disabled button', () => { + expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true); + }); + + it('does not emit change event when clicked', () => { + vm.$el.querySelector('button').click(); + + expect(vm.$emit).not.toHaveBeenCalled(); + }); + }); + + describe('is-loading', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + isLoading: true, + }); + }); + + it('renders loading class', () => { + expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true); + }); + }); +}); |