diff options
Diffstat (limited to 'spec/javascripts')
61 files changed, 2993 insertions, 368 deletions
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 1e9470970ff..e537e0e8afc 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -139,6 +139,40 @@ describe('Api', () => { }); }); + describe('projectMergeRequests', () => { + const projectPath = 'abc'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests`; + + it('fetches all merge requests for a project', done => { + const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; + mock.onGet(expectedUrl).reply(200, mockData); + Api.projectMergeRequests(projectPath) + .then(({ data }) => { + expect(data.length).toEqual(2); + expect(data[0].source_branch).toBe('foo'); + expect(data[1].source_branch).toBe('bar'); + }) + .then(done) + .catch(done.fail); + }); + + it('fetches merge requests filtered with passed params', done => { + const params = { + source_branch: 'bar', + }; + const mockData = [{ source_branch: 'bar' }]; + mock.onGet(expectedUrl, { params }).reply(200, mockData); + + Api.projectMergeRequests(projectPath, params) + .then(({ data }) => { + expect(data.length).toEqual(1); + expect(data[0].source_branch).toBe('bar'); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('projectMergeRequest', () => { it('fetches a merge request', done => { const projectPath = 'abc'; diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index ce5d2022441..e5b5707dcef 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -1,12 +1,16 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import loadAwardsHandler from '~/awards_handler'; import '~/lib/utils/common_utils'; +import { EMOJI_VERSION } from '~/emoji'; window.gl = window.gl || {}; window.gon = window.gon || {}; let openAndWaitForEmojiMenu; +let mock; let awardsHandler = null; const urlRoot = gon.relative_url_root; @@ -19,8 +23,13 @@ const lazyAssert = function(done, assertFn) { }; describe('AwardsHandler', function() { + const emojiData = getJSONFixture('emojis/emojis.json'); preloadFixtures('snippets/show.html.raw'); + beforeEach(function(done) { + mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + loadFixtures('snippets/show.html.raw'); loadAwardsHandler(true) .then(obj => { @@ -53,6 +62,8 @@ describe('AwardsHandler', function() { // restore original url root value gon.relative_url_root = urlRoot; + mock.restore(); + // Undo what we did to the shared <body> $('body').removeAttr('data-page'); diff --git a/spec/javascripts/badges/store/actions_spec.js b/spec/javascripts/badges/store/actions_spec.js index 2623465ebd6..e8d5f8c3aac 100644 --- a/spec/javascripts/badges/store/actions_spec.js +++ b/spec/javascripts/badges/store/actions_spec.js @@ -411,7 +411,7 @@ describe('Badges store actions', () => { it('escapes user input', done => { spyOn(axios, 'get').and.callFake(() => Promise.resolve({ data: createDummyBadgeResponse() })); - badgeInForm.imageUrl = '&make-sandwhich=true'; + badgeInForm.imageUrl = '&make-sandwich=true'; badgeInForm.linkUrl = '<script>I am dangerous!</script>'; actions @@ -422,7 +422,7 @@ describe('Badges store actions', () => { expect(url).toMatch(`^${dummyEndpointUrl}/render?`); expect(url).toMatch('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'); - expect(url).toMatch('&image_url=%26make-sandwhich%3Dtrue$'); + expect(url).toMatch('&image_url=%26make-sandwich%3Dtrue$'); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js index ca849f75860..d653fca0988 100644 --- a/spec/javascripts/behaviors/copy_as_gfm_spec.js +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -100,7 +100,7 @@ describe('CopyAsGFM', () => { simulateCopy(); setTimeout(() => { - const expectedGFM = '* List Item1\n\n* List Item2'; + const expectedGFM = '* List Item1\n* List Item2'; expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); done(); @@ -114,7 +114,7 @@ describe('CopyAsGFM', () => { simulateCopy(); setTimeout(() => { - const expectedGFM = '1. List Item1\n\n1. List Item2'; + const expectedGFM = '1. List Item1\n1. List Item2'; expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); done(); diff --git a/spec/javascripts/boards/components/issue_due_date_spec.js b/spec/javascripts/boards/components/issue_due_date_spec.js index 054cf8c5b7d..68e26b68f04 100644 --- a/spec/javascripts/boards/components/issue_due_date_spec.js +++ b/spec/javascripts/boards/components/issue_due_date_spec.js @@ -43,7 +43,7 @@ describe('Issue Due Date component', () => { date.setDate(date.getDate() + 5); vm = createComponent(date); - expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, 'dddd', true)); + expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, 'dddd')); }); it('should render month and day for other dates', () => { @@ -53,7 +53,7 @@ describe('Issue Due Date component', () => { const isDueInCurrentYear = today.getFullYear() === date.getFullYear(); const format = isDueInCurrentYear ? 'mmm d' : 'mmm d, yyyy'; - expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, format, true)); + expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, format)); }); it('should contain the correct `.text-danger` css class for overdue issue', () => { diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index 8cb9713964e..a2dd4e93daf 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -230,7 +230,7 @@ describe('Application Row', () => { expect(upgradeBtn.innerHTML).toContain('Upgrade'); }); - it('has enabled "Retry upgrade" when APPLICATION_STATUS.UPDATE_ERRORED', () => { + it('has enabled "Retry update" when APPLICATION_STATUS.UPDATE_ERRORED', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.UPDATE_ERRORED, @@ -239,10 +239,10 @@ describe('Application Row', () => { expect(upgradeBtn).not.toBe(null); expect(vm.upgradeFailed).toBe(true); - expect(upgradeBtn.innerHTML).toContain('Retry upgrade'); + expect(upgradeBtn.innerHTML).toContain('Retry update'); }); - it('has disabled "Retry upgrade" when APPLICATION_STATUS.UPDATING', () => { + it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_STATUS.UPDATING, @@ -251,7 +251,7 @@ describe('Application Row', () => { expect(upgradeBtn).not.toBe(null); expect(vm.isUpgrading).toBe(true); - expect(upgradeBtn.innerHTML).toContain('Upgrading'); + expect(upgradeBtn.innerHTML).toContain('Updating'); }); it('clicking upgrade button emits event', () => { @@ -295,7 +295,7 @@ describe('Application Row', () => { expect(failureMessage).not.toBe(null); expect(failureMessage.innerHTML).toContain( - 'Something went wrong when upgrading GitLab Runner. Please check the logs and try again.', + 'Update failed. Please check the logs and try again.', ); }); }); diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js index 14ef1193984..8daf0282184 100644 --- a/spec/javascripts/clusters/components/applications_spec.js +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -1,7 +1,9 @@ import Vue from 'vue'; import applications from '~/clusters/components/applications.vue'; import { CLUSTER_TYPE } from '~/clusters/constants'; +import eventHub from '~/clusters/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; describe('Applications', () => { let vm; @@ -18,16 +20,8 @@ describe('Applications', () => { describe('Project cluster applications', () => { beforeEach(() => { vm = mountComponent(Applications, { + applications: APPLICATIONS_MOCK_STATE, type: CLUSTER_TYPE.PROJECT, - applications: { - helm: { title: 'Helm Tiller' }, - ingress: { title: 'Ingress' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub' }, - knative: { title: 'Knative' }, - }, }); }); @@ -64,15 +58,7 @@ describe('Applications', () => { beforeEach(() => { vm = mountComponent(Applications, { type: CLUSTER_TYPE.GROUP, - applications: { - helm: { title: 'Helm Tiller' }, - ingress: { title: 'Ingress' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub' }, - knative: { title: 'Knative' }, - }, + applications: APPLICATIONS_MOCK_STATE, }); }); @@ -111,17 +97,12 @@ describe('Applications', () => { it('renders ip address with a clipboard button', () => { vm = mountComponent(Applications, { applications: { + ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed', externalIp: '0.0.0.0', }, - helm: { title: 'Helm Tiller' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '' }, - knative: { title: 'Knative', hostname: '' }, }, }); @@ -137,16 +118,11 @@ describe('Applications', () => { it('renders an input text with a question mark and an alert text', () => { vm = mountComponent(Applications, { applications: { + ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed', }, - helm: { title: 'Helm Tiller' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '' }, - knative: { title: 'Knative', hostname: '' }, }, }); @@ -160,15 +136,7 @@ describe('Applications', () => { describe('before installing', () => { it('does not render the IP address', () => { vm = mountComponent(Applications, { - applications: { - helm: { title: 'Helm Tiller' }, - ingress: { title: 'Ingress' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '' }, - knative: { title: 'Knative', hostname: '' }, - }, + applications: APPLICATIONS_MOCK_STATE, }); expect(vm.$el.textContent).not.toContain('Ingress IP Address'); @@ -181,17 +149,12 @@ describe('Applications', () => { it('renders email & allows editing', () => { vm = mountComponent(Applications, { applications: { - helm: { title: 'Helm Tiller', status: 'installed' }, - ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, + ...APPLICATIONS_MOCK_STATE, cert_manager: { title: 'Cert-Manager', email: 'before@example.com', status: 'installable', }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '', status: 'installable' }, - knative: { title: 'Knative', hostname: '', status: 'installable' }, }, }); @@ -204,17 +167,12 @@ describe('Applications', () => { it('renders email in readonly', () => { vm = mountComponent(Applications, { applications: { - helm: { title: 'Helm Tiller', status: 'installed' }, - ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, + ...APPLICATIONS_MOCK_STATE, cert_manager: { title: 'Cert-Manager', email: 'after@example.com', status: 'installed', }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '', status: 'installable' }, - knative: { title: 'Knative', hostname: '', status: 'installable' }, }, }); @@ -229,13 +187,12 @@ describe('Applications', () => { it('renders hostname active input', () => { vm = mountComponent(Applications, { applications: { - helm: { title: 'Helm Tiller', status: 'installed' }, - ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '', status: 'installable' }, - knative: { title: 'Knative', hostname: '', status: 'installable' }, + ...APPLICATIONS_MOCK_STATE, + ingress: { + title: 'Ingress', + status: 'installed', + externalIp: '1.1.1.1', + }, }, }); @@ -247,13 +204,8 @@ describe('Applications', () => { it('does not render hostname input', () => { vm = mountComponent(Applications, { applications: { - helm: { title: 'Helm Tiller', status: 'installed' }, + ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', hostname: '', status: 'installable' }, - knative: { title: 'Knative', hostname: '', status: 'installable' }, }, }); @@ -265,13 +217,9 @@ describe('Applications', () => { it('renders readonly input', () => { vm = mountComponent(Applications, { applications: { - helm: { title: 'Helm Tiller', status: 'installed' }, + ...APPLICATIONS_MOCK_STATE, ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, jupyter: { title: 'JupyterHub', status: 'installed', hostname: '' }, - knative: { title: 'Knative', status: 'installed', hostname: '' }, }, }); @@ -282,15 +230,7 @@ describe('Applications', () => { describe('without ingress installed', () => { beforeEach(() => { vm = mountComponent(Applications, { - applications: { - helm: { title: 'Helm Tiller' }, - ingress: { title: 'Ingress' }, - cert_manager: { title: 'Cert-Manager' }, - runner: { title: 'GitLab Runner' }, - prometheus: { title: 'Prometheus' }, - jupyter: { title: 'JupyterHub', status: 'not_installable' }, - knative: { title: 'Knative' }, - }, + applications: APPLICATIONS_MOCK_STATE, }); }); @@ -310,4 +250,77 @@ describe('Applications', () => { }); }); }); + + describe('Knative application', () => { + describe('when installed', () => { + describe('with ip address', () => { + const props = { + applications: { + ...APPLICATIONS_MOCK_STATE, + knative: { + title: 'Knative', + hostname: 'example.com', + status: 'installed', + externalIp: '1.1.1.1', + }, + }, + }; + it('renders ip address with a clipboard button', () => { + vm = mountComponent(Applications, props); + + expect(vm.$el.querySelector('.js-knative-ip-address').value).toEqual('1.1.1.1'); + + expect( + vm.$el + .querySelector('.js-knative-ip-clipboard-btn') + .getAttribute('data-clipboard-text'), + ).toEqual('1.1.1.1'); + }); + + it('renders domain & allows editing', () => { + expect(vm.$el.querySelector('.js-knative-domainname').value).toEqual('example.com'); + expect(vm.$el.querySelector('.js-knative-domainname').getAttribute('readonly')).toBe( + null, + ); + }); + + it('renders an update/save Knative domain button', () => { + expect(vm.$el.querySelector('.js-knative-save-domain-button')).not.toBe(null); + }); + + it('emits event when clicking Save changes button', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(Applications, props); + + const saveButton = vm.$el.querySelector('.js-knative-save-domain-button'); + + saveButton.click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('saveKnativeDomain', { + id: 'knative', + params: { hostname: 'example.com' }, + }); + }); + }); + + describe('without ip address', () => { + it('renders an input text with a question mark and an alert text', () => { + vm = mountComponent(Applications, { + applications: { + ...APPLICATIONS_MOCK_STATE, + knative: { + title: 'Knative', + hostname: 'example.com', + status: 'installed', + }, + }, + }); + + expect(vm.$el.querySelector('.js-knative-ip-address').value).toEqual('?'); + + expect(vm.$el.querySelector('.js-no-knative-ip-message')).not.toBe(null); + }); + }); + }); + }); }); diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js index 3c3d9977ffb..3ace19c6401 100644 --- a/spec/javascripts/clusters/services/mock_data.js +++ b/spec/javascripts/clusters/services/mock_data.js @@ -115,4 +115,14 @@ const DEFAULT_APPLICATION_STATE = { requestReason: null, }; -export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE }; +const APPLICATIONS_MOCK_STATE = { + helm: { title: 'Helm Tiller', status: 'installable' }, + ingress: { title: 'Ingress', status: 'installable' }, + cert_manager: { title: 'Cert-Manager', status: 'installable' }, + runner: { title: 'GitLab Runner' }, + prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub', status: 'installable', hostname: '' }, + knative: { title: 'Knative ', status: 'installable', hostname: '' }, +}; + +export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE }; diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js index 37a4d6614f6..09bcdf91d91 100644 --- a/spec/javascripts/clusters/stores/clusters_store_spec.js +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -111,6 +111,7 @@ describe('Clusters Store', () => { requestStatus: null, requestReason: null, hostname: null, + isEditingHostName: false, externalIp: null, }, cert_manager: { diff --git a/spec/javascripts/diffs/components/app_spec.js b/spec/javascripts/diffs/components/app_spec.js index bce6113f75a..d81c433cca6 100644 --- a/spec/javascripts/diffs/components/app_spec.js +++ b/spec/javascripts/diffs/components/app_spec.js @@ -1,11 +1,19 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import App from '~/diffs/components/app.vue'; import NoChanges from '~/diffs/components/no_changes.vue'; import DiffFile from '~/diffs/components/diff_file.vue'; import Mousetrap from 'mousetrap'; +import CompareVersions from '~/diffs/components/compare_versions.vue'; +import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; +import CommitWidget from '~/diffs/components/commit_widget.vue'; +import TreeList from '~/diffs/components/tree_list.vue'; import createDiffsStore from '../create_diffs_store'; +import diffsMockData from '../mock_data/merge_request_diffs'; + +const mergeRequestDiff = { version_index: 1 }; describe('diffs/components/app', () => { const oldMrTabs = window.mrTabs; @@ -49,6 +57,21 @@ describe('diffs/components/app', () => { wrapper.destroy(); }); + it('displays loading icon on loading', () => { + createComponent({}, ({ state }) => { + state.diffs.isLoading = true; + }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('displays diffs container when not loading', () => { + createComponent(); + + expect(wrapper.contains(GlLoadingIcon)).toBe(false); + expect(wrapper.contains('#diffs')).toBe(true); + }); + it('does not show commit info', () => { createComponent(); @@ -134,8 +157,8 @@ describe('diffs/components/app', () => { }); it('does not render empty state when diff files exist', () => { - createComponent({}, () => { - store.state.diffs.diffFiles.push({ + createComponent({}, ({ state }) => { + state.diffs.diffFiles.push({ id: 1, }); }); @@ -145,9 +168,9 @@ describe('diffs/components/app', () => { }); it('does not render empty state when versions match', () => { - createComponent({}, () => { - store.state.diffs.startVersion = { version_index: 1 }; - store.state.diffs.mergeRequestDiff = { version_index: 1 }; + createComponent({}, ({ state }) => { + state.diffs.startVersion = mergeRequestDiff; + state.diffs.mergeRequestDiff = mergeRequestDiff; }); expect(wrapper.contains(NoChanges)).toBe(false); @@ -307,4 +330,71 @@ describe('diffs/components/app', () => { .catch(done.fail); }); }); + + describe('diffs', () => { + it('should render compare versions component', () => { + createComponent({}, ({ state }) => { + state.diffs.mergeRequestDiffs = diffsMockData; + state.diffs.targetBranchName = 'target-branch'; + state.diffs.mergeRequestDiff = mergeRequestDiff; + }); + + expect(wrapper.contains(CompareVersions)).toBe(true); + expect(wrapper.find(CompareVersions).props()).toEqual( + jasmine.objectContaining({ + targetBranch: { + branchName: 'target-branch', + versionIndex: -1, + path: '', + }, + mergeRequestDiffs: diffsMockData, + mergeRequestDiff, + }), + ); + }); + + it('should render hidden files warning if render overflow warning is present', () => { + createComponent({}, ({ state }) => { + state.diffs.renderOverflowWarning = true; + state.diffs.realSize = '5'; + state.diffs.plainDiffPath = 'plain diff path'; + state.diffs.emailPatchPath = 'email patch path'; + state.diffs.size = 1; + }); + + expect(wrapper.contains(HiddenFilesWarning)).toBe(true); + expect(wrapper.find(HiddenFilesWarning).props()).toEqual( + jasmine.objectContaining({ + total: '5', + plainDiffPath: 'plain diff path', + emailPatchPath: 'email patch path', + visible: 1, + }), + ); + }); + + it('should display commit widget if store has a commit', () => { + createComponent({}, () => { + store.state.diffs.commit = { + author: 'John Doe', + }; + }); + + expect(wrapper.contains(CommitWidget)).toBe(true); + }); + + it('should display diff file if there are diff files', () => { + createComponent({}, ({ state }) => { + state.diffs.diffFiles.push({ sha: '123' }); + }); + + expect(wrapper.contains(DiffFile)).toBe(true); + }); + + it('should render tree list', () => { + createComponent(); + + expect(wrapper.find(TreeList).exists()).toBe(true); + }); + }); }); diff --git a/spec/javascripts/diffs/components/changed_files_dropdown_spec.js b/spec/javascripts/diffs/components/changed_files_dropdown_spec.js deleted file mode 100644 index 7237274eb43..00000000000 --- a/spec/javascripts/diffs/components/changed_files_dropdown_spec.js +++ /dev/null @@ -1 +0,0 @@ -// TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 diff --git a/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js b/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js index 53b9ac22fc0..8a3834d542f 100644 --- a/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js +++ b/spec/javascripts/diffs/components/compare_versions_dropdown_spec.js @@ -1,34 +1,161 @@ -// TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 import { shallowMount, createLocalVue } from '@vue/test-utils'; import CompareVersionsDropdown from '~/diffs/components/compare_versions_dropdown.vue'; import diffsMockData from '../mock_data/merge_request_diffs'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +const localVue = createLocalVue(); +const targetBranch = { branchName: 'tmp-wine-dev', versionIndex: -1 }; +const startVersion = { version_index: 4 }; +const mergeRequestVersion = { + version_path: '123', +}; +const baseVersionPath = '/gnuwget/wget2/merge_requests/6/diffs?diff_id=37'; describe('CompareVersionsDropdown', () => { let wrapper; - const targetBranch = { branchName: 'tmp-wine-dev', versionIndex: -1 }; - const factory = (options = {}) => { - const localVue = createLocalVue(); + const findSelectedVersion = () => wrapper.find('.dropdown-menu-toggle'); + const findVersionsListElements = () => wrapper.findAll('li'); + const findLinkElement = index => + findVersionsListElements() + .at(index) + .find('a'); + const findLastLink = () => findLinkElement(findVersionsListElements().length - 1); - wrapper = shallowMount(CompareVersionsDropdown, { localVue, ...options }); + const createComponent = (props = {}) => { + wrapper = shallowMount(localVue.extend(CompareVersionsDropdown), { + localVue, + sync: false, + propsData: { ...props }, + }); }; afterEach(() => { wrapper.destroy(); }); - it('should render a correct base version link', () => { - factory({ - propsData: { - baseVersionPath: '/gnuwget/wget2/merge_requests/6/diffs?diff_id=37', + describe('selected version name', () => { + it('shows latest version when latest is selected', () => { + createComponent({ + mergeRequestVersion, + startVersion, + otherVersions: diffsMockData, + }); + + expect(findSelectedVersion().text()).toBe('latest version'); + }); + + it('shows target branch name for base branch', () => { + createComponent({ + targetBranch, + }); + + expect(findSelectedVersion().text()).toBe('tmp-wine-dev'); + }); + + it('shows correct version for non-base and non-latest branches', () => { + createComponent({ + startVersion, + targetBranch, + }); + + expect(findSelectedVersion().text()).toBe(`version ${startVersion.version_index}`); + }); + }); + + describe('target versions list', () => { + it('should have the same length as otherVersions if merge request version is present', () => { + createComponent({ + mergeRequestVersion, + otherVersions: diffsMockData, + }); + + expect(findVersionsListElements().length).toEqual(diffsMockData.length); + }); + + it('should have an otherVersions length plus 1 if no merge request version is present', () => { + createComponent({ + targetBranch, + otherVersions: diffsMockData, + }); + + expect(findVersionsListElements().length).toEqual(diffsMockData.length + 1); + }); + + it('should have base branch link as active on base branch', () => { + createComponent({ + targetBranch, + otherVersions: diffsMockData, + }); + + expect(findLastLink().classes()).toContain('is-active'); + }); + + it('should have correct branch link as active if start version present', () => { + createComponent({ + targetBranch, + startVersion, + otherVersions: diffsMockData, + }); + + expect(findLinkElement(0).classes()).toContain('is-active'); + }); + + it('should render a correct base version link', () => { + createComponent({ + baseVersionPath, otherVersions: diffsMockData.slice(1), targetBranch, - }, + }); + + expect(findLastLink().attributes('href')).toEqual(baseVersionPath); + expect(findLastLink().text()).toContain('(base)'); + }); + + it('should not render commits count if no showCommitsCount is passed', () => { + createComponent({ + otherVersions: diffsMockData, + targetBranch, + }); + + const commitsCount = diffsMockData[0].commits_count; + + expect(findLinkElement(0).text()).not.toContain(`${commitsCount} commit`); + }); + + it('should render correct commits count if showCommitsCount is passed', () => { + createComponent({ + otherVersions: diffsMockData, + targetBranch, + showCommitCount: true, + }); + + const commitsCount = diffsMockData[0].commits_count; + + expect(findLinkElement(0).text()).toContain(`${commitsCount} commit`); + }); + + it('should render correct commit sha', () => { + createComponent({ + otherVersions: diffsMockData, + targetBranch, + }); + + const commitShaElement = findLinkElement(0).find('.commit-sha'); + + expect(commitShaElement.text()).toBe(diffsMockData[0].short_commit_sha); }); - const links = wrapper.findAll('a'); - const lastLink = links.wrappers[links.length - 1]; + it('should render correct time-ago ', () => { + createComponent({ + otherVersions: diffsMockData, + targetBranch, + }); + + const timeAgoElement = findLinkElement(0).find(TimeAgo); - expect(lastLink.attributes('href')).toEqual(wrapper.props('baseVersionPath')); + expect(timeAgoElement.exists()).toBe(true); + expect(timeAgoElement.props('time')).toBe(diffsMockData[0].created_at); + }); }); }); diff --git a/spec/javascripts/diffs/components/diff_content_spec.js b/spec/javascripts/diffs/components/diff_content_spec.js index a1bb51963d6..bc9288e4150 100644 --- a/spec/javascripts/diffs/components/diff_content_spec.js +++ b/spec/javascripts/diffs/components/diff_content_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import DiffContentComponent from '~/diffs/components/diff_content.vue'; -import { createStore } from '~/mr_notes/stores'; +import { createStore } from 'ee_else_ce/mr_notes/stores'; import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; import '~/behaviors/markdown/render_gfm'; diff --git a/spec/javascripts/diffs/components/diff_file_header_spec.js b/spec/javascripts/diffs/components/diff_file_header_spec.js index 005a4751ea1..66c5b17b825 100644 --- a/spec/javascripts/diffs/components/diff_file_header_spec.js +++ b/spec/javascripts/diffs/components/diff_file_header_spec.js @@ -23,6 +23,9 @@ describe('diff_file_header', () => { }); beforeEach(() => { + gon.features = { + expandDiffFullFile: true, + }; const diffFile = diffDiscussionMock.diff_file; diffFile.added_lines = 2; @@ -382,7 +385,7 @@ describe('diff_file_header', () => { props.diffFile.edit_path = '/'; vm = mountComponentWithStore(Component, { props, store }); - expect(vm.$el.querySelector('.js-edit-blob')).toContainText('Edit'); + expect(vm.$el.querySelector('.js-edit-blob')).not.toBe(null); }); it('should not show edit button when file is deleted', () => { @@ -491,5 +494,151 @@ describe('diff_file_header', () => { }); }); }); + + describe('file actions', () => { + it('should not render if diff file has a submodule', () => { + props.diffFile.submodule = 'submodule'; + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.file-actions')).toEqual(null); + }); + + it('should not render if add merge request buttons is false', () => { + props.addMergeRequestButtons = false; + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.file-actions')).toEqual(null); + }); + + describe('with add merge request buttons enabled', () => { + beforeEach(() => { + props.addMergeRequestButtons = true; + props.diffFile.edit_path = 'edit-path'; + }); + + const viewReplacedFileButton = () => vm.$el.querySelector('.js-view-replaced-file'); + const viewFileButton = () => vm.$el.querySelector('.js-view-file-button'); + const externalUrl = () => vm.$el.querySelector('.js-external-url'); + + it('should render if add merge request buttons is true and diff file does not have a submodule', () => { + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.file-actions')).not.toEqual(null); + }); + + it('should not render view replaced file button if no replaced view path is present', () => { + vm = mountComponentWithStore(Component, { props, store }); + + expect(viewReplacedFileButton()).toEqual(null); + }); + + it('should render view replaced file button if replaced view path is present', () => { + props.diffFile.replaced_view_path = 'replaced-view-path'; + vm = mountComponentWithStore(Component, { props, store }); + + expect(viewReplacedFileButton()).not.toEqual(null); + expect(viewReplacedFileButton().getAttribute('href')).toBe('replaced-view-path'); + }); + + it('should render correct file view button path', () => { + props.diffFile.view_path = 'view-path'; + vm = mountComponentWithStore(Component, { props, store }); + + expect(viewFileButton().getAttribute('href')).toBe('view-path'); + }); + + it('should not render external url view link if diff file has no external url', () => { + vm = mountComponentWithStore(Component, { props, store }); + + expect(externalUrl()).toEqual(null); + }); + + it('should render external url view link if diff file has external url', () => { + props.diffFile.external_url = 'external_url'; + vm = mountComponentWithStore(Component, { props, store }); + + expect(externalUrl()).not.toEqual(null); + expect(externalUrl().getAttribute('href')).toBe('external_url'); + }); + }); + + describe('without file blob', () => { + beforeEach(() => { + props.diffFile.blob = null; + props.addMergeRequestButtons = true; + vm = mountComponentWithStore(Component, { props, store }); + }); + + it('should not render toggle discussions button', () => { + expect(vm.$el.querySelector('.js-btn-vue-toggle-comments')).toEqual(null); + }); + + it('should not render edit button', () => { + expect(vm.$el.querySelector('.js-edit-blob')).toEqual(null); + }); + }); + }); + }); + + describe('expand full file button', () => { + beforeEach(() => { + props.addMergeRequestButtons = true; + props.diffFile.edit_path = '/'; + }); + + it('does not render button', () => { + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.js-expand-file')).toBe(null); + }); + + it('renders button', () => { + props.diffFile.is_fully_expanded = false; + + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.js-expand-file')).not.toBe(null); + }); + + it('shows fully expanded text', () => { + props.diffFile.is_fully_expanded = false; + props.diffFile.isShowingFullFile = true; + + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.js-expand-file').textContent).toContain('Show changes only'); + }); + + it('shows expand text', () => { + props.diffFile.is_fully_expanded = false; + + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.js-expand-file').textContent).toContain('Show full file'); + }); + + it('renders loading icon', () => { + props.diffFile.is_fully_expanded = false; + props.diffFile.isLoadingFullFile = true; + + vm = mountComponentWithStore(Component, { props, store }); + + expect(vm.$el.querySelector('.js-expand-file .loading-container')).not.toBe(null); + }); + + it('calls toggleFullDiff on click', () => { + props.diffFile.is_fully_expanded = false; + + vm = mountComponentWithStore(Component, { props, store }); + + spyOn(vm.$store, 'dispatch').and.stub(); + + vm.$el.querySelector('.js-expand-file').click(); + + expect(vm.$store.dispatch).toHaveBeenCalledWith( + 'diffs/toggleFullDiff', + props.diffFile.file_path, + ); + }); }); }); diff --git a/spec/javascripts/diffs/components/diff_file_spec.js b/spec/javascripts/diffs/components/diff_file_spec.js index 65a1c9b8f15..ba04c8c4a4c 100644 --- a/spec/javascripts/diffs/components/diff_file_spec.js +++ b/spec/javascripts/diffs/components/diff_file_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffFileComponent from '~/diffs/components/diff_file.vue'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; -import store from '~/mr_notes/stores'; +import store from 'ee_else_ce/mr_notes/stores'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import diffFileMockData from '../mock_data/diff_file'; diff --git a/spec/javascripts/diffs/components/edit_button_spec.js b/spec/javascripts/diffs/components/edit_button_spec.js index 7237274eb43..ccdae4cb312 100644 --- a/spec/javascripts/diffs/components/edit_button_spec.js +++ b/spec/javascripts/diffs/components/edit_button_spec.js @@ -1 +1,61 @@ -// TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import EditButton from '~/diffs/components/edit_button.vue'; + +const localVue = createLocalVue(); +const editPath = 'test-path'; + +describe('EditButton', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(EditButton, { + localVue, + sync: false, + propsData: { ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('has correct href attribute', () => { + createComponent({ + editPath, + canCurrentUserFork: false, + }); + + expect(wrapper.attributes('href')).toBe(editPath); + }); + + it('emits a show fork message event if current user can fork', () => { + createComponent({ + editPath, + canCurrentUserFork: true, + }); + wrapper.trigger('click'); + + expect(wrapper.emitted('showForkMessage')).toBeTruthy(); + }); + + it('doesnt emit a show fork message event if current user cannot fork', () => { + createComponent({ + editPath, + canCurrentUserFork: false, + }); + wrapper.trigger('click'); + + expect(wrapper.emitted('showForkMessage')).toBeFalsy(); + }); + + it('doesnt emit a show fork message event if current user can modify blob', () => { + createComponent({ + editPath, + canCurrentUserFork: true, + canModifyBlob: true, + }); + wrapper.trigger('click'); + + expect(wrapper.emitted('showForkMessage')).toBeFalsy(); + }); +}); diff --git a/spec/javascripts/diffs/components/hidden_files_warning_spec.js b/spec/javascripts/diffs/components/hidden_files_warning_spec.js index 7237274eb43..5bf5ddd27bd 100644 --- a/spec/javascripts/diffs/components/hidden_files_warning_spec.js +++ b/spec/javascripts/diffs/components/hidden_files_warning_spec.js @@ -1 +1,48 @@ -// TODO: https://gitlab.com/gitlab-org/gitlab-ce/issues/48034 +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; + +const localVue = createLocalVue(); +const propsData = { + total: '10', + visible: 5, + plainDiffPath: 'plain-diff-path', + emailPatchPath: 'email-patch-path', +}; + +describe('HiddenFilesWarning', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(HiddenFilesWarning, { + localVue, + sync: false, + propsData, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('has a correct plain diff URL', () => { + const plainDiffLink = wrapper.findAll('a').wrappers.filter(x => x.text() === 'Plain diff')[0]; + + expect(plainDiffLink.attributes('href')).toBe(propsData.plainDiffPath); + }); + + it('has a correct email patch URL', () => { + const emailPatchLink = wrapper.findAll('a').wrappers.filter(x => x.text() === 'Email patch')[0]; + + expect(emailPatchLink.attributes('href')).toBe(propsData.emailPatchPath); + }); + + it('has a correct visible/total files text', () => { + const filesText = wrapper.find('strong'); + + expect(filesText.text()).toBe('5 of 10'); + }); +}); diff --git a/spec/javascripts/diffs/components/inline_diff_view_spec.js b/spec/javascripts/diffs/components/inline_diff_view_spec.js index 2316ee29106..4452106580a 100644 --- a/spec/javascripts/diffs/components/inline_diff_view_spec.js +++ b/spec/javascripts/diffs/components/inline_diff_view_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import '~/behaviors/markdown/render_gfm'; import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; -import store from '~/mr_notes/stores'; +import store from 'ee_else_ce/mr_notes/stores'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import diffFileMockData from '../mock_data/diff_file'; import discussionsMockData from '../mock_data/diff_discussions'; diff --git a/spec/javascripts/diffs/components/parallel_diff_view_spec.js b/spec/javascripts/diffs/components/parallel_diff_view_spec.js index 6f6b1c41915..236bda96145 100644 --- a/spec/javascripts/diffs/components/parallel_diff_view_spec.js +++ b/spec/javascripts/diffs/components/parallel_diff_view_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue'; -import store from '~/mr_notes/stores'; +import store from 'ee_else_ce/mr_notes/stores'; import * as constants from '~/diffs/constants'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import diffFileMockData from '../mock_data/diff_file'; @@ -18,6 +18,10 @@ describe('ParallelDiffView', () => { }).$mount(); }); + afterEach(() => { + component.$destroy(); + }); + describe('assigned', () => { describe('diffLines', () => { it('should normalize lines for empty cells', () => { diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js index 4a091b4580b..fd5dd611383 100644 --- a/spec/javascripts/diffs/mock_data/diff_discussions.js +++ b/spec/javascripts/diffs/mock_data/diff_discussions.js @@ -288,6 +288,7 @@ export default { external_storage: null, old_path_html: 'CHANGELOG_OLD', new_path_html: 'CHANGELOG', + is_fully_expanded: true, context_lines_path: '/gitlab-org/gitlab-test/blob/c48ee0d1bf3b30453f5b32250ce03134beaa6d13/CHANGELOG/diff', highlighted_diff_lines: [ diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js index e47c7906fcb..070bfb2ccd0 100644 --- a/spec/javascripts/diffs/store/actions_spec.js +++ b/spec/javascripts/diffs/store/actions_spec.js @@ -30,6 +30,11 @@ import actions, { setRenderTreeList, setShowWhitespace, setRenderIt, + requestFullDiff, + receiveFullDiffSucess, + receiveFullDiffError, + fetchFullDiff, + toggleFullDiff, } from '~/diffs/store/actions'; import eventHub from '~/notes/event_hub'; import * as types from '~/diffs/store/mutation_types'; @@ -847,4 +852,129 @@ describe('DiffsStoreActions', () => { testAction(setRenderIt, 'file', {}, [{ type: types.RENDER_FILE, payload: 'file' }], [], done); }); }); + + describe('requestFullDiff', () => { + it('commits REQUEST_FULL_DIFF', done => { + testAction( + requestFullDiff, + 'file', + {}, + [{ type: types.REQUEST_FULL_DIFF, payload: 'file' }], + [], + done, + ); + }); + }); + + describe('receiveFullDiffSucess', () => { + it('commits REQUEST_FULL_DIFF', done => { + testAction( + receiveFullDiffSucess, + { filePath: 'test', data: 'test' }, + {}, + [{ type: types.RECEIVE_FULL_DIFF_SUCCESS, payload: { filePath: 'test', data: 'test' } }], + [], + done, + ); + }); + }); + + describe('receiveFullDiffError', () => { + it('commits REQUEST_FULL_DIFF', done => { + testAction( + receiveFullDiffError, + 'file', + {}, + [{ type: types.RECEIVE_FULL_DIFF_ERROR, payload: 'file' }], + [], + done, + ); + }); + }); + + describe('fetchFullDiff', () => { + let mock; + let scrollToElementSpy; + + beforeEach(() => { + scrollToElementSpy = spyOnDependency(actions, 'scrollToElement').and.stub(); + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('success', () => { + beforeEach(() => { + mock.onGet(`${gl.TEST_HOST}/context`).replyOnce(200, ['test']); + }); + + it('dispatches receiveFullDiffSucess', done => { + testAction( + fetchFullDiff, + { context_lines_path: `${gl.TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, + null, + [], + [{ type: 'receiveFullDiffSucess', payload: { filePath: 'test', data: ['test'] } }], + done, + ); + }); + + it('scrolls to element', done => { + fetchFullDiff( + { dispatch() {} }, + { context_lines_path: `${gl.TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, + ) + .then(() => { + expect(scrollToElementSpy).toHaveBeenCalledWith('#test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${gl.TEST_HOST}/context`).replyOnce(500); + }); + + it('dispatches receiveFullDiffError', done => { + testAction( + fetchFullDiff, + { context_lines_path: `${gl.TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, + null, + [], + [{ type: 'receiveFullDiffError', payload: 'test' }], + done, + ); + }); + }); + }); + + describe('toggleFullDiff', () => { + let state; + + beforeEach(() => { + state = { + diffFiles: [{ file_path: 'test', isShowingFullFile: false }], + }; + }); + + it('dispatches fetchFullDiff when file is not expanded', done => { + testAction( + toggleFullDiff, + 'test', + state, + [], + [ + { type: 'requestFullDiff', payload: 'test' }, + { type: 'fetchFullDiff', payload: state.diffFiles[0] }, + ], + done, + ); + }); + }); }); diff --git a/spec/javascripts/diffs/store/mutations_spec.js b/spec/javascripts/diffs/store/mutations_spec.js index 09ee691b602..270e7d75666 100644 --- a/spec/javascripts/diffs/store/mutations_spec.js +++ b/spec/javascripts/diffs/store/mutations_spec.js @@ -680,4 +680,66 @@ describe('DiffsStoreMutations', () => { expect(state.showWhitespace).toBe(false); }); }); + + describe('REQUEST_FULL_DIFF', () => { + it('sets isLoadingFullFile to true', () => { + const state = { + diffFiles: [{ file_path: 'test', isLoadingFullFile: false }], + }; + + mutations[types.REQUEST_FULL_DIFF](state, 'test'); + + expect(state.diffFiles[0].isLoadingFullFile).toBe(true); + }); + }); + + describe('RECEIVE_FULL_DIFF_ERROR', () => { + it('sets isLoadingFullFile to false', () => { + const state = { + diffFiles: [{ file_path: 'test', isLoadingFullFile: true }], + }; + + mutations[types.RECEIVE_FULL_DIFF_ERROR](state, 'test'); + + expect(state.diffFiles[0].isLoadingFullFile).toBe(false); + }); + }); + + describe('RECEIVE_FULL_DIFF_SUCCESS', () => { + it('sets isLoadingFullFile to false', () => { + const state = { + diffFiles: [ + { + file_path: 'test', + isLoadingFullFile: true, + isShowingFullFile: false, + highlighted_diff_lines: [], + parallel_diff_lines: [], + }, + ], + }; + + mutations[types.RECEIVE_FULL_DIFF_SUCCESS](state, { filePath: 'test', data: [] }); + + expect(state.diffFiles[0].isLoadingFullFile).toBe(false); + }); + + it('sets isShowingFullFile to true', () => { + const state = { + diffFiles: [ + { + file_path: 'test', + isLoadingFullFile: true, + isShowingFullFile: false, + highlighted_diff_lines: [], + parallel_diff_lines: [], + }, + ], + }; + + mutations[types.RECEIVE_FULL_DIFF_SUCCESS](state, { filePath: 'test', data: [] }); + + expect(state.diffFiles[0].isShowingFullFile).toBe(true); + }); + }); }); diff --git a/spec/javascripts/diffs/store/utils_spec.js b/spec/javascripts/diffs/store/utils_spec.js index 599ea9cd420..1f877910125 100644 --- a/spec/javascripts/diffs/store/utils_spec.js +++ b/spec/javascripts/diffs/store/utils_spec.js @@ -781,4 +781,49 @@ describe('DiffsStoreUtils', () => { ]); }); }); + + describe('convertExpandLines', () => { + it('converts expanded lines to normal lines', () => { + const diffLines = [ + { + type: 'match', + old_line: 1, + new_line: 1, + }, + { + type: '', + old_line: 2, + new_line: 2, + }, + ]; + + const lines = utils.convertExpandLines({ + diffLines, + data: [{ text: 'expanded' }], + typeKey: 'type', + oldLineKey: 'old_line', + newLineKey: 'new_line', + mapLine: ({ line, oldLine, newLine }) => ({ + ...line, + old_line: oldLine, + new_line: newLine, + }), + }); + + expect(lines).toEqual([ + { + text: 'expanded', + new_line: 1, + old_line: 1, + discussions: [], + hasForm: false, + }, + { + type: '', + old_line: 2, + new_line: 2, + }, + ]); + }); + }); }); diff --git a/spec/javascripts/dirty_submit/dirty_submit_form_spec.js b/spec/javascripts/dirty_submit/dirty_submit_form_spec.js index ae2a785de52..95cc90dcb0f 100644 --- a/spec/javascripts/dirty_submit/dirty_submit_form_spec.js +++ b/spec/javascripts/dirty_submit/dirty_submit_form_spec.js @@ -13,6 +13,8 @@ function expectToToggleDisableOnDirtyUpdate(submit, input) { } describe('DirtySubmitForm', () => { + DirtySubmitForm.THROTTLE_DURATION = 0; + it('disables submit until there are changes', done => { const { form, input, submit } = createForm(); diff --git a/spec/javascripts/emoji_spec.js b/spec/javascripts/emoji_spec.js index 3db4d9800f1..0ac375145be 100644 --- a/spec/javascripts/emoji_spec.js +++ b/spec/javascripts/emoji_spec.js @@ -1,4 +1,6 @@ -import { glEmojiTag } from '~/emoji'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji'; import isEmojiUnicodeSupported, { isFlagEmoji, isRainbowFlagEmoji, @@ -7,6 +9,7 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; +import installGlEmojiElement from '~/behaviors/gl_emoji'; const emptySupportMap = { personZwj: false, @@ -31,34 +34,35 @@ const emojiFixtureMap = { bomb: { name: 'bomb', moji: '💣', - unicodeVersion: '6.0', + uni: '6.0', }, construction_worker_tone5: { name: 'construction_worker_tone5', moji: '👷🏿', - unicodeVersion: '8.0', + uni: '8.0', }, five: { name: 'five', moji: '5️⃣', - unicodeVersion: '3.0', + uni: '3.0', }, grey_question: { name: 'grey_question', moji: '❔', - unicodeVersion: '6.0', + uni: '6.0', }, }; function markupToDomElement(markup) { const div = document.createElement('div'); div.innerHTML = markup; + document.body.appendChild(div); return div.firstElementChild; } -function testGlEmojiImageFallback(element, name, src) { +function testGlEmojiImageFallback(element, name) { expect(element.tagName.toLowerCase()).toBe('img'); - expect(element.getAttribute('src')).toBe(src); + expect(element.getAttribute('src')).toBe(`/-/emojis/${EMOJI_VERSION}/${name}.png`); expect(element.getAttribute('title')).toBe(`:${name}:`); expect(element.getAttribute('alt')).toBe(`:${name}:`); } @@ -68,12 +72,11 @@ const defaults = { sprite: false, }; -function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) { +function testGlEmojiElement(element, name, uni, unicodeMoji, options = {}) { const opts = Object.assign({}, defaults, options); expect(element.tagName.toLowerCase()).toBe('gl-emoji'); expect(element.dataset.name).toBe(name); - expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0); - expect(element.dataset.unicodeVersion).toBe(unicodeVersion); + expect(element.dataset.uni).toBe(uni); const fallbackSpriteClass = `emoji-${name}`; if (opts.sprite) { @@ -86,7 +89,7 @@ function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options if (opts.forceFallback && !opts.sprite) { // Check for image fallback - testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc); + testGlEmojiImageFallback(element.firstElementChild, name); } else { // Otherwise make sure things are still unicode text expect(element.textContent.trim()).toBe(unicodeMoji); @@ -94,101 +97,143 @@ function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options } describe('gl_emoji', () => { + beforeAll(() => { + installGlEmojiElement(); + }); + + let mock; + const emojiData = getJSONFixture('emojis/emojis.json'); + + beforeEach(function(done) { + mock = new MockAdapter(axios); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + + initEmojiMap() + .then(() => { + done(); + }) + .catch(() => { + done(); + }); + }); + + afterEach(function() { + mock.restore(); + }); + describe('glEmojiTag', () => { - it('bomb emoji', () => { + it('bomb emoji', done => { const emojiKey = 'bomb'; const markup = glEmojiTag(emojiFixtureMap[emojiKey].name); const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - ); + setTimeout(() => { + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].uni, + emojiFixtureMap[emojiKey].moji, + ); + done(); + }); }); - it('bomb emoji with image fallback', () => { + it('bomb emoji with image fallback', done => { const emojiKey = 'bomb'; const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { forceFallback: true, }); const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - forceFallback: true, - }, - ); + setTimeout(() => { + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].uni, + emojiFixtureMap[emojiKey].moji, + { + forceFallback: true, + }, + ); + done(); + }); }); - it('bomb emoji with sprite fallback readiness', () => { + it('bomb emoji with sprite fallback readiness', done => { const emojiKey = 'bomb'; const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { sprite: true, }); const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - sprite: true, - }, - ); + setTimeout(() => { + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].uni, + emojiFixtureMap[emojiKey].moji, + { + sprite: true, + }, + ); + done(); + }); }); - it('bomb emoji with sprite fallback', () => { + it('bomb emoji with sprite fallback', done => { const emojiKey = 'bomb'; const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { forceFallback: true, sprite: true, }); const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - forceFallback: true, - sprite: true, - }, - ); + setTimeout(() => { + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].uni, + emojiFixtureMap[emojiKey].moji, + { + forceFallback: true, + sprite: true, + }, + ); + done(); + }); }); - it('question mark when invalid emoji name given', () => { + it('question mark when invalid emoji name given', done => { const name = 'invalid_emoji'; const emojiKey = 'grey_question'; const markup = glEmojiTag(name); const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - ); + setTimeout(() => { + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].uni, + emojiFixtureMap[emojiKey].moji, + ); + done(); + }); }); - it('question mark with image fallback when invalid emoji name given', () => { + it('question mark with image fallback when invalid emoji name given', done => { const name = 'invalid_emoji'; const emojiKey = 'grey_question'; const markup = glEmojiTag(name, { forceFallback: true, }); const glEmojiElement = markupToDomElement(markup); - testGlEmojiElement( - glEmojiElement, - emojiFixtureMap[emojiKey].name, - emojiFixtureMap[emojiKey].unicodeVersion, - emojiFixtureMap[emojiKey].moji, - { - forceFallback: true, - }, - ); + setTimeout(() => { + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].uni, + emojiFixtureMap[emojiKey].moji, + { + forceFallback: true, + }, + ); + done(); + }); }); }); @@ -389,7 +434,7 @@ describe('gl_emoji', () => { const isSupported = isEmojiUnicodeSupported( unicodeSupportMap, emojiFixtureMap[emojiKey].moji, - emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].uni, ); expect(isSupported).toBeTruthy(); @@ -401,7 +446,7 @@ describe('gl_emoji', () => { const isSupported = isEmojiUnicodeSupported( unicodeSupportMap, emojiFixtureMap[emojiKey].moji, - emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].uni, ); expect(isSupported).toBeFalsy(); @@ -415,7 +460,7 @@ describe('gl_emoji', () => { const isSupported = isEmojiUnicodeSupported( unicodeSupportMap, emojiFixtureMap[emojiKey].moji, - emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].uni, ); expect(isSupported).toBeFalsy(); @@ -441,7 +486,7 @@ describe('gl_emoji', () => { const isSupported = isEmojiUnicodeSupported( unicodeSupportMap, emojiFixtureMap[emojiKey].moji, - emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].uni, ); expect(isSupported).toBeFalsy(); @@ -459,7 +504,7 @@ describe('gl_emoji', () => { const isSupported = isEmojiUnicodeSupported( unicodeSupportMap, emojiFixtureMap[emojiKey].moji, - emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].uni, ); expect(isSupported).toBeTruthy(); @@ -477,7 +522,7 @@ describe('gl_emoji', () => { const isSupported = isEmojiUnicodeSupported( unicodeSupportMap, emojiFixtureMap[emojiKey].moji, - emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].uni, ); expect(isSupported).toBeFalsy(); diff --git a/spec/javascripts/environments/confirm_rollback_modal_spec.js b/spec/javascripts/environments/confirm_rollback_modal_spec.js new file mode 100644 index 00000000000..05715bce38f --- /dev/null +++ b/spec/javascripts/environments/confirm_rollback_modal_spec.js @@ -0,0 +1,70 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; +import eventHub from '~/environments/event_hub'; + +describe('Confirm Rollback Modal Component', () => { + let environment; + + beforeEach(() => { + environment = { + name: 'test', + last_deployment: { + commit: { + short_id: 'abc0123', + }, + }, + modalId: 'test', + }; + }); + + it('should show "Rollback" when isLastDeployment is false', () => { + const component = shallowMount(ConfirmRollbackModal, { + propsData: { + environment: { + ...environment, + isLastDeployment: false, + }, + }, + }); + const modal = component.find(GlModal); + + expect(modal.attributes('title')).toContain('Rollback'); + expect(modal.attributes('title')).toContain('test'); + expect(modal.attributes('ok-title')).toBe('Rollback'); + expect(modal.text()).toContain('commit abc0123'); + expect(modal.text()).toContain('Are you sure you want to continue?'); + }); + + it('should show "Re-deploy" when isLastDeployment is true', () => { + const component = shallowMount(ConfirmRollbackModal, { + propsData: { + environment: { + ...environment, + isLastDeployment: true, + }, + }, + }); + const modal = component.find(GlModal); + + expect(modal.attributes('title')).toContain('Re-deploy'); + expect(modal.attributes('title')).toContain('test'); + expect(modal.attributes('ok-title')).toBe('Re-deploy'); + expect(modal.text()).toContain('commit abc0123'); + expect(modal.text()).toContain('Are you sure you want to continue?'); + }); + + it('should emit the "rollback" event when "ok" is clicked', () => { + environment = { ...environment, isLastDeployment: true }; + const component = shallowMount(ConfirmRollbackModal, { + propsData: { + environment, + }, + }); + const eventHubSpy = spyOn(eventHub, '$emit'); + const modal = component.find(GlModal); + modal.vm.$emit('ok'); + + expect(eventHubSpy).toHaveBeenCalledWith('rollbackEnvironment', environment); + }); +}); diff --git a/spec/javascripts/environments/environment_rollback_spec.js b/spec/javascripts/environments/environment_rollback_spec.js index 79f33c5bc8a..8c47f6a12c0 100644 --- a/spec/javascripts/environments/environment_rollback_spec.js +++ b/spec/javascripts/environments/environment_rollback_spec.js @@ -1,8 +1,11 @@ import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import eventHub from '~/environments/event_hub'; import rollbackComp from '~/environments/components/environment_rollback.vue'; describe('Rollback Component', () => { - const retryURL = 'https://gitlab.com/retry'; + const retryUrl = 'https://gitlab.com/retry'; let RollbackComponent; beforeEach(() => { @@ -13,8 +16,9 @@ describe('Rollback Component', () => { const component = new RollbackComponent({ el: document.querySelector('.test-dom-element'), propsData: { - retryUrl: retryURL, + retryUrl, isLastDeployment: true, + environment: {}, }, }).$mount(); @@ -25,11 +29,33 @@ describe('Rollback Component', () => { const component = new RollbackComponent({ el: document.querySelector('.test-dom-element'), propsData: { - retryUrl: retryURL, + retryUrl, isLastDeployment: false, + environment: {}, }, }).$mount(); expect(component.$el).toHaveSpriteIcon('redo'); }); + + it('should emit a "rollback" event on button click', () => { + const eventHubSpy = spyOn(eventHub, '$emit'); + const component = shallowMount(RollbackComponent, { + propsData: { + retryUrl, + environment: { + name: 'test', + }, + }, + }); + const button = component.find(GlButton); + + button.vm.$emit('click'); + + expect(eventHubSpy).toHaveBeenCalledWith('requestRollbackEnvironment', { + retryUrl, + isLastDeployment: true, + name: 'test', + }); + }); }); diff --git a/spec/javascripts/error_tracking/components/error_tracking_list_spec.js b/spec/javascripts/error_tracking/components/error_tracking_list_spec.js index 08bbb390993..503af3920a8 100644 --- a/spec/javascripts/error_tracking/components/error_tracking_list_spec.js +++ b/spec/javascripts/error_tracking/components/error_tracking_list_spec.js @@ -1,7 +1,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; -import { GlButton, GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon, GlTable, GlLink } from '@gitlab/ui'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -9,6 +9,7 @@ localVue.use(Vuex); describe('ErrorTrackingList', () => { let store; let wrapper; + let actions; function mountComponent({ errorTrackingEnabled = true } = {}) { wrapper = shallowMount(ErrorTrackingList, { @@ -20,12 +21,17 @@ describe('ErrorTrackingList', () => { errorTrackingEnabled, illustrationPath: 'illustration/path', }, + stubs: { + 'gl-link': GlLink, + }, }); } beforeEach(() => { - const actions = { + actions = { getErrorList: () => {}, + startPolling: () => {}, + restartPolling: jasmine.createSpy('restartPolling'), }; const state = { @@ -83,6 +89,18 @@ describe('ErrorTrackingList', () => { expect(wrapper.find(GlTable).exists()).toBeTruthy(); expect(wrapper.find(GlButton).exists()).toBeTruthy(); }); + + it('shows a message prompting to refresh', () => { + const refreshLink = wrapper.vm.$refs.empty.querySelector('a'); + + expect(refreshLink.textContent.trim()).toContain('Check again'); + }); + + it('restarts polling', () => { + wrapper.find('.js-try-again').trigger('click'); + + expect(actions.restartPolling).toHaveBeenCalled(); + }); }); describe('error tracking feature disabled', () => { diff --git a/spec/javascripts/error_tracking_settings/components/app_spec.js b/spec/javascripts/error_tracking_settings/components/app_spec.js new file mode 100644 index 00000000000..2e52a45fd34 --- /dev/null +++ b/spec/javascripts/error_tracking_settings/components/app_spec.js @@ -0,0 +1,63 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import ErrorTrackingSettings from '~/error_tracking_settings/components/app.vue'; +import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; +import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; +import createStore from '~/error_tracking_settings/store'; +import { TEST_HOST } from 'spec/test_constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('error tracking settings app', () => { + let store; + let wrapper; + + function mountComponent() { + wrapper = shallowMount(ErrorTrackingSettings, { + localVue, + store, // Override the imported store + propsData: { + initialEnabled: 'true', + initialApiHost: TEST_HOST, + initialToken: 'someToken', + initialProject: null, + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, + }, + }); + } + + beforeEach(() => { + store = createStore(); + + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('section', () => { + it('renders the form and dropdown', () => { + expect(wrapper.find(ErrorTrackingForm).exists()).toBeTruthy(); + expect(wrapper.find(ProjectDropdown).exists()).toBeTruthy(); + }); + + it('renders the Save Changes button', () => { + expect(wrapper.find('.js-error-tracking-button').exists()).toBeTruthy(); + }); + + it('enables the button by default', () => { + expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeFalsy(); + }); + + it('disables the button when saving', () => { + store.state.settingsLoading = true; + + expect(wrapper.find('.js-error-tracking-button').attributes('disabled')).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js b/spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js new file mode 100644 index 00000000000..23e57c4bbf1 --- /dev/null +++ b/spec/javascripts/error_tracking_settings/components/error_tracking_form_spec.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton, GlFormInput } from '@gitlab/ui'; +import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; +import { defaultProps } from '../mock'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('error tracking settings form', () => { + let wrapper; + + function mountComponent() { + wrapper = shallowMount(ErrorTrackingForm, { + localVue, + propsData: defaultProps, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('an empty form', () => { + it('is rendered', () => { + expect(wrapper.findAll(GlFormInput).length).toBe(2); + expect(wrapper.find(GlFormInput).attributes('id')).toBe('error-tracking-api-host'); + expect( + wrapper + .findAll(GlFormInput) + .at(1) + .attributes('id'), + ).toBe('error-tracking-token'); + + expect(wrapper.findAll(GlButton).exists()).toBe(true); + }); + + it('is rendered with labels and placeholders', () => { + const pageText = wrapper.text(); + + expect(pageText).toContain('Find your hostname in your Sentry account settings page'); + expect(pageText).toContain( + "After adding your Auth Token, use the 'Connect' button to load projects", + ); + + expect(pageText).not.toContain('Connection has failed. Re-check Auth Token and try again'); + expect( + wrapper + .findAll(GlFormInput) + .at(0) + .attributes('placeholder'), + ).toContain('https://mysentryserver.com'); + }); + }); + + describe('after a successful connection', () => { + beforeEach(() => { + wrapper.setProps({ connectSuccessful: true }); + }); + + it('shows the success checkmark', () => { + expect(wrapper.find('.js-error-tracking-connect-success').isVisible()).toBe(true); + }); + + it('does not show an error', () => { + expect(wrapper.text()).not.toContain( + 'Connection has failed. Re-check Auth Token and try again', + ); + }); + }); + + describe('after an unsuccessful connection', () => { + beforeEach(() => { + wrapper.setProps({ connectError: true }); + }); + + it('does not show the check mark', () => { + expect(wrapper.find('.js-error-tracking-connect-success').isVisible()).toBe(false); + }); + + it('shows an error', () => { + expect(wrapper.text()).toContain('Connection has failed. Re-check Auth Token and try again'); + }); + }); +}); diff --git a/spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js b/spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js new file mode 100644 index 00000000000..8e5dbe28452 --- /dev/null +++ b/spec/javascripts/error_tracking_settings/components/project_dropdown_spec.js @@ -0,0 +1,109 @@ +import _ from 'underscore'; +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; +import { defaultProps, projectList, staleProject } from '../mock'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('error tracking settings project dropdown', () => { + let wrapper; + + function mountComponent() { + wrapper = shallowMount(ProjectDropdown, { + localVue, + propsData: { + ..._.pick( + defaultProps, + 'dropdownLabel', + 'invalidProjectLabel', + 'projects', + 'projectSelectionLabel', + 'selectedProject', + 'token', + ), + hasProjects: false, + isProjectInvalid: false, + }, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('empty project list', () => { + it('renders the dropdown', () => { + expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); + expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); + }); + + it('shows helper text', () => { + expect(wrapper.find('.js-project-dropdown-label').exists()).toBeTruthy(); + expect(wrapper.find('.js-project-dropdown-label').text()).toContain( + 'To enable project selection', + ); + }); + + it('does not show an error', () => { + expect(wrapper.find('.js-project-dropdown-error').exists()).toBeFalsy(); + }); + + it('does not contain any dropdown items', () => { + expect(wrapper.find(GlDropdownItem).exists()).toBeFalsy(); + expect(wrapper.find(GlDropdown).props('text')).toBe('No projects available'); + }); + }); + + describe('populated project list', () => { + beforeEach(() => { + wrapper.setProps({ projects: _.clone(projectList), hasProjects: true }); + }); + + it('renders the dropdown', () => { + expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); + expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); + }); + + it('contains a number of dropdown items', () => { + expect(wrapper.find(GlDropdownItem).exists()).toBeTruthy(); + expect(wrapper.findAll(GlDropdownItem).length).toBe(2); + }); + }); + + describe('selected project', () => { + const selectedProject = _.clone(projectList[0]); + + beforeEach(() => { + wrapper.setProps({ projects: _.clone(projectList), selectedProject, hasProjects: true }); + }); + + it('does not show helper text', () => { + expect(wrapper.find('.js-project-dropdown-label').exists()).toBeFalsy(); + expect(wrapper.find('.js-project-dropdown-error').exists()).toBeFalsy(); + }); + }); + + describe('invalid project selected', () => { + beforeEach(() => { + wrapper.setProps({ + projects: _.clone(projectList), + selectedProject: staleProject, + isProjectInvalid: true, + }); + }); + + it('displays a error', () => { + expect(wrapper.find('.js-project-dropdown-label').exists()).toBeFalsy(); + expect(wrapper.find('.js-project-dropdown-error').exists()).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/error_tracking_settings/mock.js b/spec/javascripts/error_tracking_settings/mock.js new file mode 100644 index 00000000000..32cdba33c14 --- /dev/null +++ b/spec/javascripts/error_tracking_settings/mock.js @@ -0,0 +1,92 @@ +import createStore from '~/error_tracking_settings/store'; +import { TEST_HOST } from 'spec/test_constants'; + +const defaultStore = createStore(); + +export const projectList = [ + { + name: 'name', + slug: 'slug', + organizationName: 'organizationName', + organizationSlug: 'organizationSlug', + }, + { + name: 'name2', + slug: 'slug2', + organizationName: 'organizationName2', + organizationSlug: 'organizationSlug2', + }, +]; + +export const staleProject = { + name: 'staleName', + slug: 'staleSlug', + organizationName: 'staleOrganizationName', + organizationSlug: 'staleOrganizationSlug', +}; + +export const normalizedProject = { + name: 'name', + slug: 'slug', + organizationName: 'organization_name', + organizationSlug: 'organization_slug', +}; + +export const sampleBackendProject = { + name: normalizedProject.name, + slug: normalizedProject.slug, + organization_name: normalizedProject.organizationName, + organization_slug: normalizedProject.organizationSlug, +}; + +export const sampleFrontendSettings = { + apiHost: 'apiHost', + enabled: false, + token: 'token', + selectedProject: { + slug: normalizedProject.slug, + name: normalizedProject.name, + organizationName: normalizedProject.organizationName, + organizationSlug: normalizedProject.organizationSlug, + }, +}; + +export const transformedSettings = { + api_host: 'apiHost', + enabled: false, + token: 'token', + project: { + slug: normalizedProject.slug, + name: normalizedProject.name, + organization_name: normalizedProject.organizationName, + organization_slug: normalizedProject.organizationSlug, + }, +}; + +export const defaultProps = { + ...defaultStore.state, + ...defaultStore.getters, +}; + +export const initialEmptyState = { + apiHost: '', + enabled: false, + project: null, + token: '', + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, +}; + +export const initialPopulatedState = { + apiHost: 'apiHost', + enabled: true, + project: JSON.stringify(projectList[0]), + token: 'token', + listProjectsEndpoint: TEST_HOST, + operationsSettingsEndpoint: TEST_HOST, +}; + +export const projectWithHtmlTemplate = { + ...projectList[0], + name: '<strong>bold</strong>', +}; diff --git a/spec/javascripts/error_tracking_settings/store/actions_spec.js b/spec/javascripts/error_tracking_settings/store/actions_spec.js new file mode 100644 index 00000000000..0255b3a7aa4 --- /dev/null +++ b/spec/javascripts/error_tracking_settings/store/actions_spec.js @@ -0,0 +1,191 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'spec/helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import actionsDefaultExport, * as actions from '~/error_tracking_settings/store/actions'; +import * as types from '~/error_tracking_settings/store/mutation_types'; +import defaultState from '~/error_tracking_settings/store/state'; +import { projectList } from '../mock'; + +describe('error tracking settings actions', () => { + let state; + + describe('project list actions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { ...defaultState(), listProjectsEndpoint: TEST_HOST }; + }); + + afterEach(() => { + mock.restore(); + }); + + it('should request and transform the project list', done => { + mock.onPost(TEST_HOST).reply(() => [200, { projects: projectList }]); + testAction( + actions.fetchProjects, + null, + state, + [], + [ + { type: 'requestProjects' }, + { + type: 'receiveProjectsSuccess', + payload: projectList.map(convertObjectPropsToCamelCase), + }, + ], + () => { + expect(mock.history.post.length).toBe(1); + done(); + }, + ); + }); + + it('should handle a server error', done => { + mock.onPost(`${TEST_HOST}.json`).reply(() => [400]); + testAction( + actions.fetchProjects, + null, + state, + [], + [ + { type: 'requestProjects' }, + { + type: 'receiveProjectsError', + }, + ], + () => { + expect(mock.history.post.length).toBe(1); + done(); + }, + ); + }); + + it('should request projects correctly', done => { + testAction(actions.requestProjects, null, state, [{ type: types.RESET_CONNECT }], [], done); + }); + + it('should receive projects correctly', done => { + const testPayload = []; + testAction( + actions.receiveProjectsSuccess, + testPayload, + state, + [ + { type: types.UPDATE_CONNECT_SUCCESS }, + { type: types.RECEIVE_PROJECTS, payload: testPayload }, + ], + [], + done, + ); + }); + + it('should handle errors when receiving projects', done => { + const testPayload = []; + testAction( + actions.receiveProjectsError, + testPayload, + state, + [{ type: types.UPDATE_CONNECT_ERROR }, { type: types.CLEAR_PROJECTS }], + [], + done, + ); + }); + }); + + describe('save changes actions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { + operationsSettingsEndpoint: TEST_HOST, + }; + }); + + afterEach(() => { + mock.restore(); + }); + + it('should save the page', done => { + const refreshCurrentPage = spyOnDependency(actionsDefaultExport, 'refreshCurrentPage'); + mock.onPatch(TEST_HOST).reply(200); + testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }], () => { + expect(mock.history.patch.length).toBe(1); + expect(refreshCurrentPage).toHaveBeenCalled(); + done(); + }); + }); + + it('should handle a server error', done => { + mock.onPatch(TEST_HOST).reply(400); + testAction( + actions.updateSettings, + null, + state, + [], + [ + { type: 'requestSettings' }, + { + type: 'receiveSettingsError', + payload: new Error('Request failed with status code 400'), + }, + ], + () => { + expect(mock.history.patch.length).toBe(1); + done(); + }, + ); + }); + + it('should request to save the page', done => { + testAction( + actions.requestSettings, + null, + state, + [{ type: types.UPDATE_SETTINGS_LOADING, payload: true }], + [], + done, + ); + }); + + it('should handle errors when requesting to save the page', done => { + testAction( + actions.receiveSettingsError, + {}, + state, + [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }], + [], + done, + ); + }); + }); + + describe('generic actions to update the store', () => { + const testData = 'test'; + it('should reset the `connect success` flag when updating the api host', done => { + testAction( + actions.updateApiHost, + testData, + state, + [{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }], + [], + done, + ); + }); + + it('should reset the `connect success` flag when updating the token', done => { + testAction( + actions.updateToken, + testData, + state, + [{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }], + [], + done, + ); + }); + }); +}); diff --git a/spec/javascripts/error_tracking_settings/store/getters_spec.js b/spec/javascripts/error_tracking_settings/store/getters_spec.js new file mode 100644 index 00000000000..2c5ff084b8a --- /dev/null +++ b/spec/javascripts/error_tracking_settings/store/getters_spec.js @@ -0,0 +1,93 @@ +import * as getters from '~/error_tracking_settings/store/getters'; +import defaultState from '~/error_tracking_settings/store/state'; +import { projectList, projectWithHtmlTemplate, staleProject } from '../mock'; + +describe('Error Tracking Settings - Getters', () => { + let state; + + beforeEach(() => { + state = defaultState(); + }); + + describe('hasProjects', () => { + it('should reflect when no projects exist', () => { + expect(getters.hasProjects(state)).toEqual(false); + }); + + it('should reflect when projects exist', () => { + state.projects = projectList; + + expect(getters.hasProjects(state)).toEqual(true); + }); + }); + + describe('isProjectInvalid', () => { + const mockGetters = { hasProjects: true }; + it('should show when a project is valid', () => { + state.projects = projectList; + [state.selectedProject] = projectList; + + expect(getters.isProjectInvalid(state, mockGetters)).toEqual(false); + }); + + it('should show when a project is invalid', () => { + state.projects = projectList; + state.selectedProject = staleProject; + + expect(getters.isProjectInvalid(state, mockGetters)).toEqual(true); + }); + }); + + describe('dropdownLabel', () => { + const mockGetters = { hasProjects: false }; + it('should display correctly when there are no projects available', () => { + expect(getters.dropdownLabel(state, mockGetters)).toEqual('No projects available'); + }); + + it('should display correctly when a project is selected', () => { + [state.selectedProject] = projectList; + + expect(getters.dropdownLabel(state, mockGetters)).toEqual('organizationName | name'); + }); + + it('should display correctly when no project is selected', () => { + state.projects = projectList; + + expect(getters.dropdownLabel(state, { hasProjects: true })).toEqual('Select project'); + }); + }); + + describe('invalidProjectLabel', () => { + it('should display an error containing the project name', () => { + [state.selectedProject] = projectList; + + expect(getters.invalidProjectLabel(state)).toEqual( + 'Project "name" is no longer available. Select another project to continue.', + ); + }); + + it('should properly escape the label text', () => { + state.selectedProject = projectWithHtmlTemplate; + + expect(getters.invalidProjectLabel(state)).toEqual( + 'Project "<strong>bold</strong>" is no longer available. Select another project to continue.', + ); + }); + }); + + describe('projectSelectionLabel', () => { + it('should show the correct message when the token is empty', () => { + expect(getters.projectSelectionLabel(state)).toEqual( + 'To enable project selection, enter a valid Auth Token', + ); + }); + + it('should show the correct message when token exists', () => { + state.token = 'test-token'; + + expect(getters.projectSelectionLabel(state)).toEqual( + "Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.", + ); + }); + }); +}); diff --git a/spec/javascripts/error_tracking_settings/store/mutation_spec.js b/spec/javascripts/error_tracking_settings/store/mutation_spec.js new file mode 100644 index 00000000000..bb1f1da784e --- /dev/null +++ b/spec/javascripts/error_tracking_settings/store/mutation_spec.js @@ -0,0 +1,82 @@ +import { TEST_HOST } from 'spec/test_constants'; +import mutations from '~/error_tracking_settings/store/mutations'; +import defaultState from '~/error_tracking_settings/store/state'; +import * as types from '~/error_tracking_settings/store/mutation_types'; +import { + initialEmptyState, + initialPopulatedState, + projectList, + sampleBackendProject, + normalizedProject, +} from '../mock'; + +describe('error tracking settings mutations', () => { + describe('mutations', () => { + let state; + + beforeEach(() => { + state = defaultState(); + }); + + it('should create an empty initial state correctly', () => { + mutations[types.SET_INITIAL_STATE](state, { + ...initialEmptyState, + }); + + expect(state.apiHost).toEqual(''); + expect(state.enabled).toEqual(false); + expect(state.selectedProject).toEqual(null); + expect(state.token).toEqual(''); + expect(state.listProjectsEndpoint).toEqual(TEST_HOST); + expect(state.operationsSettingsEndpoint).toEqual(TEST_HOST); + }); + + it('should populate the initial state correctly', () => { + mutations[types.SET_INITIAL_STATE](state, { + ...initialPopulatedState, + }); + + expect(state.apiHost).toEqual('apiHost'); + expect(state.enabled).toEqual(true); + expect(state.selectedProject).toEqual(projectList[0]); + expect(state.token).toEqual('token'); + expect(state.listProjectsEndpoint).toEqual(TEST_HOST); + expect(state.operationsSettingsEndpoint).toEqual(TEST_HOST); + }); + + it('should receive projects successfully', () => { + mutations[types.RECEIVE_PROJECTS](state, [sampleBackendProject]); + + expect(state.projects).toEqual([normalizedProject]); + }); + + it('should strip out unnecessary project properties', () => { + mutations[types.RECEIVE_PROJECTS](state, [ + { ...sampleBackendProject, extra_property: 'extra_property' }, + ]); + + expect(state.projects).toEqual([normalizedProject]); + }); + + it('should update state when connect is successful', () => { + mutations[types.UPDATE_CONNECT_SUCCESS](state); + + expect(state.connectSuccessful).toBe(true); + expect(state.connectError).toBe(false); + }); + + it('should update state when connect fails', () => { + mutations[types.UPDATE_CONNECT_ERROR](state); + + expect(state.connectSuccessful).toBe(false); + expect(state.connectError).toBe(true); + }); + + it('should update state when connect is reset', () => { + mutations[types.RESET_CONNECT](state); + + expect(state.connectSuccessful).toBe(false); + expect(state.connectError).toBe(false); + }); + }); +}); diff --git a/spec/javascripts/error_tracking_settings/utils_spec.js b/spec/javascripts/error_tracking_settings/utils_spec.js new file mode 100644 index 00000000000..4b144f7daf1 --- /dev/null +++ b/spec/javascripts/error_tracking_settings/utils_spec.js @@ -0,0 +1,29 @@ +import { transformFrontendSettings } from '~/error_tracking_settings/utils'; +import { sampleFrontendSettings, transformedSettings } from './mock'; + +describe('error tracking settings utils', () => { + describe('data transform functions', () => { + it('should transform settings successfully for the backend', () => { + expect(transformFrontendSettings(sampleFrontendSettings)).toEqual(transformedSettings); + }); + + it('should transform empty values in the settings object to null', () => { + const emptyFrontendSettingsObject = { + apiHost: '', + enabled: false, + token: '', + selectedProject: null, + }; + const transformedEmptySettingsObject = { + api_host: null, + enabled: false, + token: null, + project: null, + }; + + expect(transformFrontendSettings(emptyFrontendSettingsObject)).toEqual( + transformedEmptySettingsObject, + ); + }); + }); +}); diff --git a/spec/javascripts/fixtures/autocomplete_sources.rb b/spec/javascripts/fixtures/autocomplete_sources.rb deleted file mode 100644 index c117fb7cd24..00000000000 --- a/spec/javascripts/fixtures/autocomplete_sources.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', type: :controller do - include JavaScriptFixturesHelpers - - set(:admin) { create(:admin) } - set(:group) { create(:group, name: 'frontend-fixtures') } - set(:project) { create(:project, namespace: group, path: 'autocomplete-sources-project') } - set(:issue) { create(:issue, project: project) } - - before(:all) do - clean_frontend_fixtures('autocomplete_sources/') - end - - before do - sign_in(admin) - end - - it 'autocomplete_sources/labels.json' do |example| - issue.labels << create(:label, project: project, title: 'bug') - issue.labels << create(:label, project: project, title: 'critical') - - create(:label, project: project, title: 'feature') - create(:label, project: project, title: 'documentation') - - get :labels, - format: :json, - params: { - namespace_id: group.path, - project_id: project.path, - type: issue.class.name, - type_id: issue.id - } - - expect(response).to be_success - store_frontend_fixture(response, example.description) - end -end diff --git a/spec/javascripts/fixtures/emojis.rb b/spec/javascripts/fixtures/emojis.rb new file mode 100644 index 00000000000..0e7257ee681 --- /dev/null +++ b/spec/javascripts/fixtures/emojis.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe 'Emojis (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + before(:all) do + clean_frontend_fixtures('emojis/') + end + + it 'emojis/emojis.json' do |example| + # Copying the emojis.json from the public folder + fixture_file_name = File.expand_path('emojis/emojis.json', JavaScriptFixturesHelpers::FIXTURE_PATH) + FileUtils.mkdir_p(File.dirname(fixture_file_name)) + FileUtils.cp(Rails.root.join('public/-/emojis/1/emojis.json'), fixture_file_name) + end +end diff --git a/spec/javascripts/fixtures/static_fixtures.rb b/spec/javascripts/fixtures/static_fixtures.rb index 4569f16f0ca..852a82587b9 100644 --- a/spec/javascripts/fixtures/static_fixtures.rb +++ b/spec/javascripts/fixtures/static_fixtures.rb @@ -7,23 +7,23 @@ describe ApplicationController, '(Static JavaScript fixtures)', type: :controlle clean_frontend_fixtures('static/') end - fixtures_path = File.expand_path(JavaScriptFixturesHelpers::FIXTURE_PATH, Rails.root) - haml_fixtures = Dir.glob(File.expand_path('**/*.haml', fixtures_path)).map do |file_path| - file_path.sub(/\A#{fixtures_path}#{File::SEPARATOR}/, '') - end + JavaScriptFixturesHelpers::FIXTURE_PATHS.each do |fixture_path| + fixtures_path = File.expand_path(fixture_path, Rails.root) + + Dir.glob(File.expand_path('**/*.haml', fixtures_path)).map do |file_path| + template_file_name = file_path.sub(/\A#{fixtures_path}#{File::SEPARATOR}/, '') - haml_fixtures.each do |template_file_name| - it "static/#{template_file_name.sub(/\.haml\z/, '.raw')}" do |example| - fixture_file_name = example.description - rendered = render_template(template_file_name) - store_frontend_fixture(rendered, fixture_file_name) + it "static/#{template_file_name.sub(/\.haml\z/, '.raw')}" do |example| + fixture_file_name = example.description + rendered = render_template(fixture_path, template_file_name) + store_frontend_fixture(rendered, fixture_file_name) + end end end private - def render_template(template_file_name) - fixture_path = JavaScriptFixturesHelpers::FIXTURE_PATH + def render_template(fixture_path, template_file_name) controller = ApplicationController.new controller.prepend_view_path(fixture_path) controller.render_to_string(template: template_file_name, layout: false) diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js index d94cc1a8faa..d1a0964ccdd 100644 --- a/spec/javascripts/ide/components/new_dropdown/modal_spec.js +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -18,6 +18,9 @@ describe('new file modal component', () => { store.state.entryModal = { type, path: '', + entry: { + path: '', + }, }; vm = createComponentWithStore(Component, store).$mount(); @@ -74,6 +77,7 @@ describe('new file modal component', () => { entry: { name: 'test', type: 'blob', + path: 'test-path', }, }; @@ -97,7 +101,7 @@ describe('new file modal component', () => { describe('entryName', () => { it('returns entries name', () => { - expect(vm.entryName).toBe('test'); + expect(vm.entryName).toBe('test-path'); }); it('updated name', () => { @@ -107,4 +111,53 @@ describe('new file modal component', () => { }); }); }); + + describe('submitForm', () => { + it('throws an error when target entry exists', () => { + const store = createStore(); + store.state.entryModal = { + type: 'rename', + path: 'test-path/test', + entry: { + name: 'test', + type: 'blob', + path: 'test-path/test', + }, + }; + store.state.entries = { + 'test-path/test': { + name: 'test', + deleted: false, + }, + }; + + vm = createComponentWithStore(Component, store).$mount(); + const flashSpy = spyOnDependency(modal, 'flash'); + vm.submitForm(); + + expect(flashSpy).toHaveBeenCalled(); + }); + + it('calls createTempEntry when target path does not exist', () => { + const store = createStore(); + store.state.entryModal = { + type: 'rename', + path: 'test-path/test', + entry: { + name: 'test', + type: 'blob', + path: 'test-path1/test', + }, + }; + + vm = createComponentWithStore(Component, store).$mount(); + spyOn(vm, 'createTempEntry').and.callFake(() => Promise.resolve()); + vm.submitForm(); + + expect(vm.createTempEntry).toHaveBeenCalledWith({ + name: 'test-path1', + type: 'tree', + }); + }); + }); }); diff --git a/spec/javascripts/ide/lib/files_spec.js b/spec/javascripts/ide/lib/files_spec.js new file mode 100644 index 00000000000..fe791aa2b74 --- /dev/null +++ b/spec/javascripts/ide/lib/files_spec.js @@ -0,0 +1,77 @@ +import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; +import { decorateFiles, splitParent } from '~/ide/lib/files'; +import { decorateData } from '~/ide/stores/utils'; + +const TEST_BRANCH_ID = 'lorem-ipsum'; +const TEST_PROJECT_ID = 10; + +const createEntries = paths => { + const createEntry = (acc, { path, type, children }) => { + // Sometimes we need to end the url with a '/' + const createUrl = base => (type === 'tree' ? `${base}/` : base); + + const { name, parent } = splitParent(path); + const parentEntry = acc[parent]; + + acc[path] = { + ...decorateData({ + projectId: TEST_PROJECT_ID, + branchId: TEST_BRANCH_ID, + id: path, + name, + path, + url: createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}/-/${path}`), + type, + previewMode: viewerInformationForPath(path), + parentPath: parent, + parentTreeUrl: parentEntry + ? parentEntry.url + : createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}`), + }), + tree: children.map(childName => jasmine.objectContaining({ name: childName })), + }; + + return acc; + }; + + const entries = paths.reduce(createEntry, {}); + + // Wrap entries in jasmine.objectContaining. + // We couldn't do this earlier because we still need to select properties from parent entries. + return Object.keys(entries).reduce((acc, key) => { + acc[key] = jasmine.objectContaining(entries[key]); + + return acc; + }, {}); +}; + +describe('IDE lib decorate files', () => { + it('creates entries and treeList', () => { + const data = ['app/assets/apples/foo.js', 'app/bugs.js', 'README.md']; + const expectedEntries = createEntries([ + { path: 'app', type: 'tree', children: ['assets', 'bugs.js'] }, + { path: 'app/assets', type: 'tree', children: ['apples'] }, + { path: 'app/assets/apples', type: 'tree', children: ['foo.js'] }, + { path: 'app/assets/apples/foo.js', type: 'blob', children: [] }, + { path: 'app/bugs.js', type: 'blob', children: [] }, + { path: 'README.md', type: 'blob', children: [] }, + ]); + + const { entries, treeList } = decorateFiles({ + data, + branchId: TEST_BRANCH_ID, + projectId: TEST_PROJECT_ID, + }); + + // Here we test the keys and then each key/value individually because `expect(entries).toEqual(expectedEntries)` + // was taking a very long time for some reason. Probably due to large objects and nested `jasmine.objectContaining`. + const entryKeys = Object.keys(entries); + + expect(entryKeys).toEqual(Object.keys(expectedEntries)); + entryKeys.forEach(key => { + expect(entries[key]).toEqual(expectedEntries[key]); + }); + + expect(treeList).toEqual([expectedEntries.app, expectedEntries['README.md']]); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/merge_request_spec.js b/spec/javascripts/ide/stores/actions/merge_request_spec.js index 9bfc7c397b8..a5839630657 100644 --- a/spec/javascripts/ide/stores/actions/merge_request_spec.js +++ b/spec/javascripts/ide/stores/actions/merge_request_spec.js @@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import store from '~/ide/stores'; import actions, { + getMergeRequestsForBranch, getMergeRequestData, getMergeRequestChanges, getMergeRequestVersions, @@ -27,6 +28,98 @@ describe('IDE store merge request actions', () => { resetStore(store); }); + describe('getMergeRequestsForBranch', () => { + describe('success', () => { + const mrData = { iid: 2, source_branch: 'bar' }; + const mockData = [mrData]; + + describe('base case', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequests').and.callThrough(); + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, mockData); + }); + + it('calls getProjectMergeRequests service method', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: 'abcproject', branchId: 'bar' }) + .then(() => { + expect(service.getProjectMergeRequests).toHaveBeenCalledWith('abcproject', { + source_branch: 'bar', + order_by: 'created_at', + per_page: 1, + }); + + done(); + }) + .catch(done.fail); + }); + + it('sets the "Merge Request" Object', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: 'abcproject', branchId: 'bar' }) + .then(() => { + expect(Object.keys(store.state.projects.abcproject.mergeRequests).length).toEqual(1); + expect(Object.keys(store.state.projects.abcproject.mergeRequests)[0]).toEqual('2'); + expect(store.state.projects.abcproject.mergeRequests[2]).toEqual( + jasmine.objectContaining(mrData), + ); + done(); + }) + .catch(done.fail); + }); + + it('sets "Current Merge Request" object to the most recent MR', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: 'abcproject', branchId: 'bar' }) + .then(() => { + expect(store.state.currentMergeRequestId).toEqual('2'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('no merge requests for branch available case', () => { + beforeEach(() => { + spyOn(service, 'getProjectMergeRequests').and.callThrough(); + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, []); + }); + + it('does not fail if there are no merge requests for current branch', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: 'abcproject', branchId: 'foo' }) + .then(() => { + expect(Object.keys(store.state.projects.abcproject.mergeRequests).length).toEqual(0); + expect(store.state.currentMergeRequestId).toEqual(''); + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).networkError(); + }); + + it('flashes message, if error', done => { + const flashSpy = spyOnDependency(actions, 'flash'); + + getMergeRequestsForBranch({ commit() {} }, { projectId: 'abcproject', branchId: 'bar' }) + .then(() => { + fail('Expected getMergeRequestsForBranch to throw an error'); + }) + .catch(() => { + expect(flashSpy).toHaveBeenCalled(); + expect(flashSpy.calls.argsFor(0)[0]).toEqual('Error fetching merge requests for bar'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + describe('getMergeRequestData', () => { describe('success', () => { beforeEach(() => { diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js index 7d8c9edd965..7b0963713fb 100644 --- a/spec/javascripts/ide/stores/actions/project_spec.js +++ b/spec/javascripts/ide/stores/actions/project_spec.js @@ -249,6 +249,7 @@ describe('IDE store project actions', () => { ['setCurrentBranchId', branch.branchId], ['getBranchData', branch], ['getFiles', branch], + ['getMergeRequestsForBranch', branch], ]); }) .then(done) diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js index bd41e87bf0e..fbb676aab33 100644 --- a/spec/javascripts/ide/stores/actions/tree_spec.js +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -20,6 +20,7 @@ describe('Multi-file store tree actions', () => { }; beforeEach(() => { + jasmine.clock().install(); spyOn(router, 'push'); mock = new MockAdapter(axios); @@ -37,6 +38,7 @@ describe('Multi-file store tree actions', () => { }); afterEach(() => { + jasmine.clock().uninstall(); mock.restore(); resetStore(store); }); @@ -70,6 +72,11 @@ describe('Multi-file store tree actions', () => { store .dispatch('getFiles', basicCallParameters) .then(() => { + // The populating of the tree is deferred for performance reasons. + // See this merge request for details: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/25700 + jasmine.clock().tick(1); + }) + .then(() => { projectTree = store.state.trees['abcproject/master']; expect(projectTree.tree.length).toBe(2); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index df291ade3f7..0b5587d02ae 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -499,12 +499,12 @@ describe('Multi-file store actions', () => { testAction( renameEntry, - { path: 'test', name: 'new-name' }, + { path: 'test', name: 'new-name', entryPath: null, parentPath: 'parent-path' }, store.state, [ { type: types.RENAME_ENTRY, - payload: { path: 'test', name: 'new-name', entryPath: null }, + payload: { path: 'test', name: 'new-name', entryPath: null, parentPath: 'parent-path' }, }, ], [{ type: 'deleteEntry', payload: 'test' }], @@ -527,17 +527,33 @@ describe('Multi-file store actions', () => { testAction( renameEntry, - { path: 'test', name: 'new-name' }, + { path: 'test', name: 'new-name', parentPath: 'parent-path' }, store.state, [ { type: types.RENAME_ENTRY, - payload: { path: 'test', name: 'new-name', entryPath: null }, + payload: { path: 'test', name: 'new-name', entryPath: null, parentPath: 'parent-path' }, }, ], [ - { type: 'renameEntry', payload: { path: 'test', name: 'new-name', entryPath: 'tree-1' } }, - { type: 'renameEntry', payload: { path: 'test', name: 'new-name', entryPath: 'tree-2' } }, + { + type: 'renameEntry', + payload: { + path: 'test', + name: 'new-name', + entryPath: 'tree-1', + parentPath: 'parent-path/new-name', + }, + }, + { + type: 'renameEntry', + payload: { + path: 'test', + name: 'new-name', + entryPath: 'tree-2', + parentPath: 'parent-path/new-name', + }, + }, { type: 'deleteEntry', payload: 'test' }, ], done, diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js index 41dd3d3c67f..5ee098bf17f 100644 --- a/spec/javascripts/ide/stores/mutations_spec.js +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -298,7 +298,12 @@ describe('Multi-file store mutations', () => { }); it('creates new renamed entry', () => { - mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + mutations.RENAME_ENTRY(localState, { + path: 'oldPath', + name: 'newPath', + entryPath: null, + parentPath: '', + }); expect(localState.entries.newPath).toEqual({ ...localState.entries.oldPath, @@ -335,7 +340,12 @@ describe('Multi-file store mutations', () => { ...file(), }; - mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + mutations.RENAME_ENTRY(localState, { + path: 'oldPath', + name: 'newPath', + entryPath: null, + parentPath: 'parentPath', + }); expect(localState.entries.parentPath.tree.length).toBe(1); }); diff --git a/spec/javascripts/import_projects/components/import_projects_table_spec.js b/spec/javascripts/import_projects/components/import_projects_table_spec.js index a1ff84ce259..ab8642bf0dd 100644 --- a/spec/javascripts/import_projects/components/import_projects_table_spec.js +++ b/spec/javascripts/import_projects/components/import_projects_table_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import store from '~/import_projects/store'; +import createStore from '~/import_projects/store'; import importProjectsTable from '~/import_projects/components/import_projects_table.vue'; import STATUS_MAP from '~/import_projects/constants'; import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; @@ -9,6 +9,7 @@ import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; describe('ImportProjectsTable', () => { let vm; let mock; + let store; const reposPath = '/repos-path'; const jobsPath = '/jobs-path'; const providerTitle = 'THE PROVIDER'; @@ -31,12 +32,13 @@ describe('ImportProjectsTable', () => { }, }).$mount(); - component.$store.dispatch('stopJobsPolling'); + store.dispatch('stopJobsPolling'); return component; } beforeEach(() => { + store = createStore(); store.dispatch('setInitialData', { reposPath }); mock = new MockAdapter(axios); }); @@ -167,7 +169,7 @@ describe('ImportProjectsTable', () => { expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); mock.onGet(jobsPath).replyOnce(200, updatedProjects); - return vm.$store.dispatch('restartJobsPolling'); + return store.dispatch('restartJobsPolling'); }) .then(() => setTimeoutPromise()) .then(() => { diff --git a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js b/spec/javascripts/import_projects/components/imported_project_table_row_spec.js index 8af3b5954a9..7dac7e9ccc1 100644 --- a/spec/javascripts/import_projects/components/imported_project_table_row_spec.js +++ b/spec/javascripts/import_projects/components/imported_project_table_row_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import store from '~/import_projects/store'; +import createStore from '~/import_projects/store'; import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; import STATUS_MAP from '~/import_projects/constants'; @@ -16,6 +16,7 @@ describe('ImportedProjectTableRow', () => { function createComponent() { const ImportedProjectTableRow = Vue.extend(importedProjectTableRow); + const store = createStore(); return new ImportedProjectTableRow({ store, propsData: { diff --git a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js b/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js index 69377f8d685..4d2bacd2ad0 100644 --- a/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/javascripts/import_projects/components/provider_repo_table_row_spec.js @@ -1,12 +1,13 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import store from '~/import_projects/store'; +import createStore from '~/import_projects/store'; import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import STATUS_MAP, { STATUSES } from '~/import_projects/constants'; import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; describe('ProviderRepoTableRow', () => { + let store; let vm; const repo = { id: 10, @@ -28,6 +29,10 @@ describe('ProviderRepoTableRow', () => { }).$mount(); } + beforeEach(() => { + store = createStore(); + }); + afterEach(() => { vm.$destroy(); }); diff --git a/spec/javascripts/jobs/store/getters_spec.js b/spec/javascripts/jobs/store/getters_spec.js index 7931b2af79f..379114c3737 100644 --- a/spec/javascripts/jobs/store/getters_spec.js +++ b/spec/javascripts/jobs/store/getters_spec.js @@ -151,6 +151,61 @@ describe('Job Store Getters', () => { }); }); + describe('shouldRenderSharedRunnerLimitWarning', () => { + describe('without runners information', () => { + it('returns false', () => { + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false); + }); + }); + + describe('with runners information', () => { + describe('when used quota is less than limit', () => { + it('returns false', () => { + localState.job.runners = { + quota: { + used: 33, + limit: 2000, + }, + available: true, + online: true, + }; + + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false); + }); + }); + + describe('when used quota is equal to limit', () => { + it('returns true', () => { + localState.job.runners = { + quota: { + used: 2000, + limit: 2000, + }, + available: true, + online: true, + }; + + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true); + }); + }); + + describe('when used quota is bigger than limit', () => { + it('returns true', () => { + localState.job.runners = { + quota: { + used: 2002, + limit: 2000, + }, + available: true, + online: true, + }; + + expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true); + }); + }); + }); + }); + describe('hasRunnersForProject', () => { describe('with available and offline runners', () => { it('returns true', () => { diff --git a/spec/javascripts/lib/utils/number_utility_spec.js b/spec/javascripts/lib/utils/number_utility_spec.js index 94c6214c86a..818404bad81 100644 --- a/spec/javascripts/lib/utils/number_utility_spec.js +++ b/spec/javascripts/lib/utils/number_utility_spec.js @@ -4,6 +4,7 @@ import { bytesToMiB, bytesToGiB, numberToHumanSize, + sum, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -87,4 +88,14 @@ describe('Number Utils', () => { expect(numberToHumanSize(10737418240)).toEqual('10.00 GiB'); }); }); + + describe('sum', () => { + it('should add up two values', () => { + expect(sum(1, 2)).toEqual(3); + }); + + it('should add up all the values in an array when passed to a reducer', () => { + expect([1, 2, 3, 4, 5].reduce(sum)).toEqual(15); + }); + }); }); diff --git a/spec/javascripts/monitoring/charts/area_spec.js b/spec/javascripts/monitoring/charts/area_spec.js index d334ef7ba4f..fb49290be19 100644 --- a/spec/javascripts/monitoring/charts/area_spec.js +++ b/spec/javascripts/monitoring/charts/area_spec.js @@ -7,6 +7,7 @@ import MonitoringMock, { deploymentData } from '../mock_data'; describe('Area component', () => { const mockWidgets = 'mockWidgets'; + const mockSvgPathContent = 'mockSvgPathContent'; let mockGraphData; let areaChart; let spriteSpy; @@ -30,7 +31,7 @@ describe('Area component', () => { }); spriteSpy = spyOnDependency(Area, 'getSvgIconPathContent').and.callFake( - () => new Promise(resolve => resolve()), + () => new Promise(resolve => resolve(mockSvgPathContent)), ); }); @@ -74,15 +75,6 @@ describe('Area component', () => { expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipTitle', mockTitle)).toBe(true); }); - it('recieves tooltip content', () => { - const mockContent = 'mockContent'; - areaChart.vm.tooltip.content = mockContent; - - expect(shallowWrapperContainsSlotText(glAreaChart, 'tooltipContent', mockContent)).toBe( - true, - ); - }); - describe('when tooltip is showing deployment data', () => { beforeEach(() => { areaChart.vm.tooltip.isDeployment = true; @@ -110,6 +102,7 @@ describe('Area component', () => { const generateSeriesData = type => ({ seriesData: [ { + seriesName: areaChart.vm.chartData[0].name, componentSubType: type, value: [mockDate, 5.55555], }, @@ -127,7 +120,14 @@ describe('Area component', () => { }); it('formats tooltip content', () => { - expect(areaChart.vm.tooltip.content).toBe('CPU 5.556'); + expect(areaChart.vm.tooltip.content).toEqual([{ name: 'Core Usage', value: '5.556' }]); + expect( + shallowWrapperContainsSlotText( + areaChart.find(GlAreaChart), + 'tooltipContent', + 'Core Usage 5.556', + ), + ).toBe(true); }); }); @@ -146,24 +146,31 @@ describe('Area component', () => { }); }); - describe('getScatterSymbol', () => { + describe('setSvg', () => { + const mockSvgName = 'mockSvgName'; + beforeEach(() => { - areaChart.vm.getScatterSymbol(); + areaChart.vm.setSvg(mockSvgName); }); - it('gets rocket svg path content for use as deployment data symbol', () => { - expect(spriteSpy).toHaveBeenCalledWith('rocket'); + it('gets svg path content', () => { + expect(spriteSpy).toHaveBeenCalledWith(mockSvgName); + }); + + it('sets svg path content', done => { + areaChart.vm.$nextTick(() => { + expect(areaChart.vm.svgs[mockSvgName]).toBe(`path://${mockSvgPathContent}`); + done(); + }); }); }); describe('onResize', () => { const mockWidth = 233; - const mockHeight = 144; beforeEach(() => { spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({ width: mockWidth, - height: mockHeight, })); areaChart.vm.onResize(); }); @@ -171,22 +178,25 @@ describe('Area component', () => { it('sets area chart width', () => { expect(areaChart.vm.width).toBe(mockWidth); }); - - it('sets area chart height', () => { - expect(areaChart.vm.height).toBe(mockHeight); - }); }); }); describe('computed', () => { describe('chartData', () => { + let chartData; + const seriesData = () => chartData[0]; + + beforeEach(() => { + ({ chartData } = areaChart.vm); + }); + it('utilizes all data points', () => { - expect(Object.keys(areaChart.vm.chartData)).toEqual(['Cores']); - expect(areaChart.vm.chartData.Cores.length).toBe(297); + expect(chartData.length).toBe(1); + expect(seriesData().data.length).toBe(297); }); it('creates valid data', () => { - const data = areaChart.vm.chartData.Cores; + const { data } = seriesData(); expect( data.filter(([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number') @@ -205,12 +215,6 @@ describe('Area component', () => { }); }); - describe('xAxisLabel', () => { - it('constructs a label for the chart x-axis', () => { - expect(areaChart.vm.xAxisLabel).toBe('Core Usage'); - }); - }); - describe('yAxisLabel', () => { it('constructs a label for the chart y-axis', () => { expect(areaChart.vm.yAxisLabel).toBe('CPU'); diff --git a/spec/javascripts/notes/components/note_form_spec.js b/spec/javascripts/notes/components/note_form_spec.js index 5db20fd285f..7cc324cfe44 100644 --- a/spec/javascripts/notes/components/note_form_spec.js +++ b/spec/javascripts/notes/components/note_form_spec.js @@ -1,17 +1,15 @@ -import Vue from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import createStore from '~/notes/stores'; -import issueNoteForm from '~/notes/components/note_form.vue'; +import NoteForm from '~/notes/components/note_form.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { noteableDataMock, notesDataMock } from '../mock_data'; -import { keyboardDownEvent } from '../../issue_show/helpers'; describe('issue_note_form component', () => { let store; - let vm; + let wrapper; let props; beforeEach(() => { - const Component = Vue.extend(issueNoteForm); - store = createStore(); store.dispatch('setNoteableData', noteableDataMock); store.dispatch('setNotesData', notesDataMock); @@ -22,26 +20,35 @@ describe('issue_note_form component', () => { noteId: '545', }; - vm = new Component({ + const localVue = createLocalVue(); + wrapper = shallowMount(NoteForm, { store, propsData: props, - }).$mount(); + // see https://gitlab.com/gitlab-org/gitlab-ce/issues/56317 for the following + localVue, + sync: false, + }); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('noteHash', () => { it('returns note hash string based on `noteId`', () => { - expect(vm.noteHash).toBe(`#note_${props.noteId}`); + expect(wrapper.vm.noteHash).toBe(`#note_${props.noteId}`); }); it('return note hash as `#` when `noteId` is empty', done => { - vm.noteId = ''; - Vue.nextTick() + wrapper.setProps({ + ...props, + noteId: '', + }); + + wrapper.vm + .$nextTick() .then(() => { - expect(vm.noteHash).toBe('#'); + expect(wrapper.vm.noteHash).toBe('#'); }) .then(done) .catch(done.fail); @@ -50,95 +57,127 @@ describe('issue_note_form component', () => { describe('conflicts editing', () => { it('should show conflict message if note changes outside the component', done => { - vm.isEditing = true; - vm.noteBody = 'Foo'; + wrapper.setProps({ + ...props, + isEditing: true, + noteBody: 'Foo', + }); + const message = 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; - Vue.nextTick(() => { - expect( - vm.$el - .querySelector('.js-conflict-edit-warning') - .textContent.replace(/\s+/g, ' ') - .trim(), - ).toEqual(message); - done(); - }); + wrapper.vm + .$nextTick() + .then(() => { + const conflictWarning = wrapper.find('.js-conflict-edit-warning'); + + expect(conflictWarning.exists()).toBe(true); + expect( + conflictWarning + .text() + .replace(/\s+/g, ' ') + .trim(), + ).toBe(message); + }) + .then(done) + .catch(done.fail); }); }); describe('form', () => { it('should render text area with placeholder', () => { - expect(vm.$el.querySelector('textarea').getAttribute('placeholder')).toEqual( + const textarea = wrapper.find('textarea'); + + expect(textarea.attributes('placeholder')).toEqual( 'Write a comment or drag your files here…', ); }); it('should link to markdown docs', () => { const { markdownDocsPath } = notesDataMock; + const markdownField = wrapper.find(MarkdownField); + const markdownFieldProps = markdownField.props(); - expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual( - 'Markdown', - ); + expect(markdownFieldProps.markdownDocsPath).toBe(markdownDocsPath); }); describe('keyboard events', () => { + let textarea; + + beforeEach(() => { + textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + }); + describe('up', () => { it('should ender edit mode', () => { - spyOn(vm, 'editMyLastNote').and.callThrough(); - vm.$el.querySelector('textarea').value = 'Foo'; - vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(38, true)); + // TODO: do not spy on vm + spyOn(wrapper.vm, 'editMyLastNote').and.callThrough(); + + textarea.trigger('keydown.up'); - expect(vm.editMyLastNote).toHaveBeenCalled(); + expect(wrapper.vm.editMyLastNote).toHaveBeenCalled(); }); }); describe('enter', () => { it('should save note when cmd+enter is pressed', () => { - spyOn(vm, 'handleUpdate').and.callThrough(); - vm.$el.querySelector('textarea').value = 'Foo'; - vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true)); + textarea.trigger('keydown.enter', { metaKey: true }); + + const { handleFormUpdate } = wrapper.emitted(); - expect(vm.handleUpdate).toHaveBeenCalled(); + expect(handleFormUpdate.length).toBe(1); }); it('should save note when ctrl+enter is pressed', () => { - spyOn(vm, 'handleUpdate').and.callThrough(); - vm.$el.querySelector('textarea').value = 'Foo'; - vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, false, true)); + textarea.trigger('keydown.enter', { ctrlKey: true }); - expect(vm.handleUpdate).toHaveBeenCalled(); + const { handleFormUpdate } = wrapper.emitted(); + + expect(handleFormUpdate.length).toBe(1); }); }); }); describe('actions', () => { it('should be possible to cancel', done => { - spyOn(vm, 'cancelHandler').and.callThrough(); - vm.isEditing = true; + // TODO: do not spy on vm + spyOn(wrapper.vm, 'cancelHandler').and.callThrough(); + wrapper.setProps({ + ...props, + isEditing: true, + }); - Vue.nextTick(() => { - vm.$el.querySelector('.note-edit-cancel').click(); + wrapper.vm + .$nextTick() + .then(() => { + const cancelButton = wrapper.find('.note-edit-cancel'); + cancelButton.trigger('click'); - Vue.nextTick(() => { - expect(vm.cancelHandler).toHaveBeenCalled(); - done(); - }); - }); + expect(wrapper.vm.cancelHandler).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); it('should be possible to update the note', done => { - vm.isEditing = true; - - Vue.nextTick(() => { - vm.$el.querySelector('textarea').value = 'Foo'; - vm.$el.querySelector('.js-vue-issue-save').click(); - - Vue.nextTick(() => { - expect(vm.isSubmitting).toEqual(true); - done(); - }); + wrapper.setProps({ + ...props, + isEditing: true, }); + + wrapper.vm + .$nextTick() + .then(() => { + const textarea = wrapper.find('textarea'); + textarea.setValue('Foo'); + const saveButton = wrapper.find('.js-vue-issue-save'); + saveButton.trigger('click'); + + expect(wrapper.vm.isSubmitting).toEqual(true); + }) + .then(done) + .catch(done.fail); }); }); }); diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index c066975a43b..8f3c493dd4c 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -261,4 +261,12 @@ describe('Getters Notes Store', () => { expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy(); }); }); + + describe('getDiscussion', () => { + it('returns discussion by ID', () => { + state.discussions.push({ id: '1' }); + + expect(getters.getDiscussion(state)('1')).toEqual({ id: '1' }); + }); + }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index fcad1f245b6..4a640d589fb 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import mutations from '~/notes/stores/mutations'; +import { DISCUSSION_NOTE } from '~/notes/constants'; import { note, discussionMock, @@ -326,6 +327,18 @@ describe('Notes Store mutations', () => { expect(state.discussions[0].notes[0].note).toEqual('Foo'); }); + + it('transforms an individual note to discussion', () => { + const state = { + discussions: [individualNote], + }; + + const transformedNote = { ...individualNote.notes[0], type: DISCUSSION_NOTE }; + + mutations.UPDATE_NOTE(state, transformedNote); + + expect(state.discussions[0].individual_note).toEqual(false); + }); }); describe('CLOSE_ISSUE', () => { @@ -530,7 +543,7 @@ describe('Notes Store mutations', () => { state = { convertedDisscussionIds: [] }; }); - it('adds a disucssion to convertedDisscussionIds', () => { + it('adds a discussion to convertedDisscussionIds', () => { mutations.CONVERT_TO_DISCUSSION(state, discussion.id); expect(state.convertedDisscussionIds).toContain(discussion.id); @@ -549,7 +562,7 @@ describe('Notes Store mutations', () => { state = { convertedDisscussionIds: [41, 42] }; }); - it('removes a disucssion from convertedDisscussionIds', () => { + it('removes a discussion from convertedDisscussionIds', () => { mutations.REMOVE_CONVERTED_DISCUSSION(state, discussion.id); expect(state.convertedDisscussionIds).not.toContain(discussion.id); diff --git a/spec/javascripts/persistent_user_callout_spec.js b/spec/javascripts/persistent_user_callout_spec.js new file mode 100644 index 00000000000..2fdfff3db03 --- /dev/null +++ b/spec/javascripts/persistent_user_callout_spec.js @@ -0,0 +1,88 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import PersistentUserCallout from '~/persistent_user_callout'; +import setTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; + +describe('PersistentUserCallout', () => { + const dismissEndpoint = '/dismiss'; + const featureName = 'feature'; + + function createFixture() { + const fixture = document.createElement('div'); + fixture.innerHTML = ` + <div + class="container" + data-dismiss-endpoint="${dismissEndpoint}" + data-feature-id="${featureName}" + > + <button type="button" class="js-close"></button> + </div> + `; + + return fixture; + } + + describe('dismiss', () => { + let button; + let mockAxios; + let persistentUserCallout; + + beforeEach(() => { + const fixture = createFixture(); + const container = fixture.querySelector('.container'); + button = fixture.querySelector('.js-close'); + mockAxios = new MockAdapter(axios); + persistentUserCallout = new PersistentUserCallout(container); + spyOn(persistentUserCallout.container, 'remove'); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('POSTs endpoint and removes container when clicking close', done => { + mockAxios.onPost(dismissEndpoint).replyOnce(200); + + button.click(); + + setTimeoutPromise() + .then(() => { + expect(persistentUserCallout.container.remove).toHaveBeenCalled(); + expect(mockAxios.history.post[0].data).toBe( + JSON.stringify({ feature_name: featureName }), + ); + }) + .then(done) + .catch(done.fail); + }); + + it('invokes Flash when the dismiss request fails', done => { + const Flash = spyOnDependency(PersistentUserCallout, 'Flash'); + mockAxios.onPost(dismissEndpoint).replyOnce(500); + + button.click(); + + setTimeoutPromise() + .then(() => { + expect(persistentUserCallout.container.remove).not.toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledWith( + 'An error occurred while dismissing the alert. Refresh the page and try again.', + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('factory', () => { + it('returns an instance of PersistentUserCallout with the provided container property', () => { + const fixture = createFixture(); + + expect(PersistentUserCallout.factory(fixture) instanceof PersistentUserCallout).toBe(true); + }); + + it('returns undefined if container is falsey', () => { + expect(PersistentUserCallout.factory()).toBe(undefined); + }); + }); +}); diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index b2b0a50911d..5eef5682bbd 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -8,6 +8,7 @@ import '~/commons'; import Vue from 'vue'; import VueResource from 'vue-resource'; import Translate from '~/vue_shared/translate'; +import CheckEE from '~/vue_shared/mixins/is_ee'; import jasmineDiff from 'jasmine-diff'; import { getDefaultAdapter } from '~/lib/utils/axios_utils'; @@ -43,6 +44,7 @@ Vue.config.errorHandler = function(err) { Vue.use(VueResource); Vue.use(Translate); +Vue.use(CheckEE); // enable test fixtures jasmine.getFixtures().fixturesPath = FIXTURES_PATH; @@ -67,6 +69,7 @@ window.gl = window.gl || {}; window.gl.TEST_HOST = TEST_HOST; window.gon = window.gon || {}; window.gon.test_env = true; +window.gon.ee = false; gon.relative_url_root = ''; let hasUnhandledPromiseRejections = false; diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 631da202d1d..08e173b0a10 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -673,7 +673,7 @@ describe('ReadyToMerge', () => { .at(0) .props('label'); - it('should have two edit components when squash is enabled', () => { + it('should have two edit components when squash is enabled and there is more than 1 commit', () => { createLocalComponent({ mr: { commitsCount: 2, @@ -685,6 +685,18 @@ describe('ReadyToMerge', () => { expect(findCommitEditElements().length).toBe(2); }); + it('should have one edit components when squash is enabled and there is 1 commit only', () => { + createLocalComponent({ + mr: { + commitsCount: 1, + squash: true, + enableSquashBeforeMerge: true, + }, + }); + + expect(findCommitEditElements().length).toBe(1); + }); + it('should have correct edit merge commit label', () => { createLocalComponent(); @@ -711,8 +723,10 @@ describe('ReadyToMerge', () => { expect(findCommitDropdownElement().exists()).toBeFalsy(); }); - it('should be rendered if squash is enabled', () => { - createLocalComponent({ mr: { squash: true } }); + it('should be rendered if squash is enabled and there is more than 1 commit', () => { + createLocalComponent({ + mr: { enableSquashBeforeMerge: true, squash: true, commitsCount: 2 }, + }); expect(findCommitDropdownElement().exists()).toBeTruthy(); }); diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js b/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js new file mode 100644 index 00000000000..42198e92eea --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/related_issuable_item_spec.js @@ -0,0 +1,194 @@ +import Vue from 'vue'; +import { mount, createLocalVue } from '@vue/test-utils'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import { defaultMilestone, defaultAssignees } from './related_issuable_mock_data'; + +describe('RelatedIssuableItem', () => { + let wrapper; + const props = { + idKey: 1, + displayReference: 'gitlab-org/gitlab-test#1', + pathIdSeparator: '#', + path: `${gl.TEST_HOST}/path`, + title: 'title', + confidential: true, + dueDate: '1990-12-31', + weight: 10, + createdAt: '2018-12-01T00:00:00.00Z', + milestone: defaultMilestone, + assignees: defaultAssignees, + eventNamespace: 'relatedIssue', + }; + const slots = { + dueDate: '<div class="js-due-date-slot"></div>', + weight: '<div class="js-weight-slot"></div>', + }; + + beforeEach(() => { + const localVue = createLocalVue(); + + wrapper = mount(localVue.extend(RelatedIssuableItem), { + localVue, + slots, + sync: false, + propsData: props, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains issuable-info-container class when canReorder is false', () => { + expect(wrapper.props('canReorder')).toBe(false); + expect(wrapper.find('.issuable-info-container').exists()).toBe(true); + }); + + it('does not render token state', () => { + expect(wrapper.find('.text-secondary svg').exists()).toBe(false); + }); + + it('does not render remove button', () => { + expect(wrapper.find({ ref: 'removeButton' }).exists()).toBe(false); + }); + + describe('token title', () => { + it('links to computedPath', () => { + expect(wrapper.find('.item-title a').attributes('href')).toEqual(wrapper.props('path')); + }); + + it('renders confidential icon', () => { + expect(wrapper.find('.confidential-icon').exists()).toBe(true); + }); + + it('renders title', () => { + expect(wrapper.find('.item-title a').text()).toEqual(props.title); + }); + }); + + describe('token state', () => { + let tokenState; + + beforeEach(done => { + wrapper.setProps({ state: 'opened' }); + + Vue.nextTick(() => { + tokenState = wrapper.find('.issue-token-state-icon-open'); + + done(); + }); + }); + + it('renders if hasState', () => { + expect(tokenState.exists()).toBe(true); + }); + + it('renders state title', () => { + const stateTitle = tokenState.attributes('data-original-title'); + + expect(stateTitle).toContain('<span class="bold">Opened</span>'); + expect(stateTitle).toContain( + '<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>', + ); + }); + + it('renders aria label', () => { + expect(tokenState.attributes('aria-label')).toEqual('opened'); + }); + + it('renders open icon when open state', () => { + expect(tokenState.classes('issue-token-state-icon-open')).toBe(true); + }); + + it('renders close icon when close state', done => { + wrapper.setProps({ + state: 'closed', + closedAt: '2018-12-01T00:00:00.00Z', + }); + + Vue.nextTick(() => { + expect(tokenState.classes('issue-token-state-icon-closed')).toBe(true); + + done(); + }); + }); + }); + + describe('token metadata', () => { + let tokenMetadata; + + beforeEach(done => { + Vue.nextTick(() => { + tokenMetadata = wrapper.find('.item-meta'); + + done(); + }); + }); + + it('renders item path and ID', () => { + const pathAndID = tokenMetadata.find('.item-path-id').text(); + + expect(pathAndID).toContain('gitlab-org/gitlab-test'); + expect(pathAndID).toContain('#1'); + }); + + it('renders milestone icon and name', () => { + const milestoneIcon = tokenMetadata.find('.item-milestone svg use'); + const milestoneTitle = tokenMetadata.find('.item-milestone .milestone-title'); + + expect(milestoneIcon.attributes('href')).toContain('clock'); + expect(milestoneTitle.text()).toContain('Milestone title'); + }); + + it('renders due date component', () => { + expect(tokenMetadata.find('.js-due-date-slot').exists()).toBe(true); + }); + + it('renders weight component', () => { + expect(tokenMetadata.find('.js-weight-slot').exists()).toBe(true); + }); + }); + + describe('token assignees', () => { + it('renders assignees avatars', () => { + expect(wrapper.findAll('.item-assignees .user-avatar-link').length).toBe(2); + expect(wrapper.find('.item-assignees .avatar-counter').text()).toContain('+2'); + }); + }); + + describe('remove button', () => { + let removeBtn; + + beforeEach(done => { + wrapper.setProps({ canRemove: true }); + Vue.nextTick(() => { + removeBtn = wrapper.find({ ref: 'removeButton' }); + + done(); + }); + }); + + it('renders if canRemove', () => { + expect(removeBtn.exists()).toBe(true); + }); + + it('renders disabled button when removeDisabled', done => { + wrapper.vm.removeDisabled = true; + + Vue.nextTick(() => { + expect(removeBtn.attributes('disabled')).toEqual('disabled'); + + done(); + }); + }); + + it('triggers onRemoveRequest when clicked', () => { + removeBtn.trigger('click'); + + const { relatedIssueRemoveRequest } = wrapper.emitted(); + + expect(relatedIssueRemoveRequest.length).toBe(1); + expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js b/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js new file mode 100644 index 00000000000..26bfdd7551e --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/related_issuable_mock_data.js @@ -0,0 +1,111 @@ +export const defaultProps = { + endpoint: '/foo/bar/issues/1/related_issues', + currentNamespacePath: 'foo', + currentProjectPath: 'bar', +}; + +export const issuable1 = { + id: 200, + epic_issue_id: 1, + confidential: false, + reference: 'foo/bar#123', + displayReference: '#123', + title: 'some title', + path: '/foo/bar/issues/123', + state: 'opened', +}; + +export const issuable2 = { + id: 201, + epic_issue_id: 2, + confidential: false, + reference: 'foo/bar#124', + displayReference: '#124', + title: 'some other thing', + path: '/foo/bar/issues/124', + state: 'opened', +}; + +export const issuable3 = { + id: 202, + epic_issue_id: 3, + confidential: false, + reference: 'foo/bar#125', + displayReference: '#125', + title: 'some other other thing', + path: '/foo/bar/issues/125', + state: 'opened', +}; + +export const issuable4 = { + id: 203, + epic_issue_id: 4, + confidential: false, + reference: 'foo/bar#126', + displayReference: '#126', + title: 'some other other other thing', + path: '/foo/bar/issues/126', + state: 'opened', +}; + +export const issuable5 = { + id: 204, + epic_issue_id: 5, + confidential: false, + reference: 'foo/bar#127', + displayReference: '#127', + title: 'some other other other thing', + path: '/foo/bar/issues/127', + state: 'opened', +}; + +export const defaultMilestone = { + id: 1, + state: 'active', + title: 'Milestone title', + start_date: '2018-01-01', + due_date: '2019-12-31', +}; + +export const defaultAssignees = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/root`, + status_tooltip_html: null, + path: '/root', + }, + { + id: 13, + name: 'Brooks Beatty', + username: 'brynn_champlin', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/brynn_champlin`, + status_tooltip_html: null, + path: '/brynn_champlin', + }, + { + id: 6, + name: 'Bryce Turcotte', + username: 'melynda', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/melynda`, + status_tooltip_html: null, + path: '/melynda', + }, + { + id: 20, + name: 'Conchita Eichmann', + username: 'juliana_gulgowski', + state: 'active', + avatar_url: `${gl.TEST_HOST}`, + web_url: `${gl.TEST_HOST}/juliana_gulgowski`, + status_tooltip_html: null, + path: '/juliana_gulgowski', + }, +]; diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js b/spec/javascripts/vue_shared/components/table_pagination_spec.js index 407d1d59f83..42cd41381dc 100644 --- a/spec/javascripts/vue_shared/components/table_pagination_spec.js +++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js @@ -22,10 +22,10 @@ describe('Pagination component', () => { it('should not render anything', () => { component = mountComponent({ pageInfo: { - nextPage: 1, + nextPage: NaN, page: 1, perPage: 20, - previousPage: null, + previousPage: NaN, total: 15, totalPages: 1, }, @@ -58,6 +58,28 @@ describe('Pagination component', () => { expect(spy).not.toHaveBeenCalled(); }); + it('should be disabled and non clickable when total and totalPages are NaN', () => { + component = mountComponent({ + pageInfo: { + nextPage: 2, + page: 1, + perPage: 20, + previousPage: NaN, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + expect( + component.$el.querySelector('.js-previous-button').classList.contains('disabled'), + ).toEqual(true); + + component.$el.querySelector('.js-previous-button .page-link').click(); + + expect(spy).not.toHaveBeenCalled(); + }); + it('should be enabled and clickable', () => { component = mountComponent({ pageInfo: { @@ -75,6 +97,24 @@ describe('Pagination component', () => { expect(spy).toHaveBeenCalledWith(1); }); + + it('should be enabled and clickable when total and totalPages are NaN', () => { + component = mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + component.$el.querySelector('.js-previous-button .page-link').click(); + + expect(spy).toHaveBeenCalledWith(1); + }); }); describe('first button', () => { @@ -99,6 +139,28 @@ describe('Pagination component', () => { expect(spy).toHaveBeenCalledWith(1); }); + + it('should call the change callback with the first page when total and totalPages are NaN', () => { + component = mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + const button = component.$el.querySelector('.js-first-button .page-link'); + + expect(button.textContent.trim()).toEqual('« First'); + + button.click(); + + expect(spy).toHaveBeenCalledWith(1); + }); }); describe('last button', () => { @@ -123,16 +185,32 @@ describe('Pagination component', () => { expect(spy).toHaveBeenCalledWith(5); }); + + it('should not render', () => { + component = mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + expect(component.$el.querySelector('.js-last-button .page-link')).toBeNull(); + }); }); describe('next button', () => { it('should be disabled and non clickable', () => { component = mountComponent({ pageInfo: { - nextPage: 5, + nextPage: NaN, page: 5, perPage: 20, - previousPage: 1, + previousPage: 4, total: 84, totalPages: 5, }, @@ -146,6 +224,26 @@ describe('Pagination component', () => { expect(spy).not.toHaveBeenCalled(); }); + it('should be disabled and non clickable when total and totalPages are NaN', () => { + component = mountComponent({ + pageInfo: { + nextPage: NaN, + page: 5, + perPage: 20, + previousPage: 4, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + expect(component.$el.querySelector('.js-next-button').textContent.trim()).toEqual('Next'); + + component.$el.querySelector('.js-next-button .page-link').click(); + + expect(spy).not.toHaveBeenCalled(); + }); + it('should be enabled and clickable', () => { component = mountComponent({ pageInfo: { @@ -163,6 +261,24 @@ describe('Pagination component', () => { expect(spy).toHaveBeenCalledWith(4); }); + + it('should be enabled and clickable when total and totalPages are NaN', () => { + component = mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + component.$el.querySelector('.js-next-button .page-link').click(); + + expect(spy).toHaveBeenCalledWith(4); + }); }); describe('numbered buttons', () => { @@ -181,22 +297,56 @@ describe('Pagination component', () => { expect(component.$el.querySelectorAll('.page').length).toEqual(5); }); + + it('should not render any page', () => { + component = mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + expect(component.$el.querySelectorAll('.page').length).toEqual(0); + }); }); - it('should render the spread operator', () => { - component = mountComponent({ - pageInfo: { - nextPage: 4, - page: 3, - perPage: 20, - previousPage: 2, - total: 84, - totalPages: 10, - }, - change: spy, + describe('spread operator', () => { + it('should render', () => { + component = mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: 84, + totalPages: 10, + }, + change: spy, + }); + + expect(component.$el.querySelector('.separator').textContent.trim()).toEqual('...'); }); - expect(component.$el.querySelector('.separator').textContent.trim()).toEqual('...'); + it('should not render', () => { + component = mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + + expect(component.$el.querySelector('.separator')).toBeNull(); + }); }); }); }); |