diff options
Diffstat (limited to 'spec/frontend')
131 files changed, 11253 insertions, 731 deletions
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 62ba0d36982..cef50bf553c 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -467,6 +467,26 @@ describe('Api', () => { }); }); + describe('user projects', () => { + it('fetches all projects that belong to a particular user', done => { + const query = 'dummy query'; + const options = { unused: 'option' }; + const userId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/projects`; + mock.onGet(expectedUrl).reply(200, [ + { + name: 'test', + }, + ]); + + Api.userProjects(userId, query, options, response => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + done(); + }); + }); + }); + describe('commitPipelines', () => { it('fetches pipelines for a given commit', done => { const projectId = 'example/foobar'; diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js new file mode 100644 index 00000000000..0a16dfbc009 --- /dev/null +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -0,0 +1,81 @@ +import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; +import boardsStore from '~/boards/stores/boards_store'; +import { shallowMount } from '@vue/test-utils'; + +describe('Issue Time Estimate component', () => { + let wrapper; + + beforeEach(() => { + boardsStore.create(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when limitToHours is false', () => { + beforeEach(() => { + boardsStore.timeTracking.limitToHours = false; + wrapper = shallowMount(IssueTimeEstimate, { + propsData: { + estimate: 374460, + }, + sync: false, + }); + }); + + it('renders the correct time estimate', () => { + expect( + wrapper + .find('time') + .text() + .trim(), + ).toEqual('2w 3d 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); + }); + + it('prevents tooltip xss', done => { + const alertSpy = jest.spyOn(window, 'alert'); + wrapper.setProps({ estimate: 'Foo <script>alert("XSS")</script>' }); + wrapper.vm.$nextTick(() => { + expect(alertSpy).not.toHaveBeenCalled(); + expect( + wrapper + .find('time') + .text() + .trim(), + ).toEqual('0m'); + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); + done(); + }); + }); + }); + + describe('when limitToHours is true', () => { + beforeEach(() => { + boardsStore.timeTracking.limitToHours = true; + wrapper = shallowMount(IssueTimeEstimate, { + propsData: { + estimate: 374460, + }, + sync: false, + }); + }); + + it('renders the correct time estimate', () => { + expect( + wrapper + .find('time') + .text() + .trim(), + ).toEqual('104h 1m'); + }); + + it('renders expanded time estimate in tooltip', () => { + expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); + }); + }); +}); diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js new file mode 100644 index 00000000000..ebe97769ab7 --- /dev/null +++ b/spec/frontend/boards/issue_card_spec.js @@ -0,0 +1,307 @@ +/* global ListAssignee, ListLabel, ListIssue */ +import { mount } from '@vue/test-utils'; +import _ from 'underscore'; +import '~/boards/models/label'; +import '~/boards/models/assignee'; +import '~/boards/models/issue'; +import '~/boards/models/list'; +import IssueCardInner from '~/boards/components/issue_card_inner.vue'; +import { listObj } from '../../javascripts/boards/mock_data'; +import store from '~/boards/stores'; + +describe('Issue card component', () => { + const user = new ListAssignee({ + id: 1, + name: 'testing 123', + username: 'test', + avatar: 'test_image', + }); + + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: 'blue', + text_color: 'white', + description: 'test', + }); + + let wrapper; + let issue; + let list; + + beforeEach(() => { + list = { ...listObj, type: 'label' }; + issue = new ListIssue({ + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [list.label], + assignees: [], + reference_path: '#1', + real_path: '/test/1', + weight: 1, + }); + wrapper = mount(IssueCardInner, { + propsData: { + list, + issue, + issueLinkBase: '/test', + rootPath: '/', + }, + store, + sync: false, + }); + }); + + it('renders issue title', () => { + expect(wrapper.find('.board-card-title').text()).toContain(issue.title); + }); + + it('includes issue base in link', () => { + expect(wrapper.find('.board-card-title a').attributes('href')).toContain('/test'); + }); + + it('includes issue title on link', () => { + expect(wrapper.find('.board-card-title a').attributes('title')).toBe(issue.title); + }); + + it('does not render confidential icon', () => { + expect(wrapper.find('.fa-eye-flash').exists()).toBe(false); + }); + + it('renders confidential icon', done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + confidential: true, + }, + }); + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.confidential-icon').exists()).toBe(true); + done(); + }); + }); + + it('renders issue ID with #', () => { + expect(wrapper.find('.board-card-number').text()).toContain(`#${issue.id}`); + }); + + describe('assignee', () => { + it('does not render assignee', () => { + expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(false); + }); + + describe('exists', () => { + beforeEach(done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [user], + }, + }); + + wrapper.vm.$nextTick(done); + }); + + it('renders assignee', () => { + expect(wrapper.find('.board-card-assignee .avatar').exists()).toBe(true); + }); + + it('sets title', () => { + expect(wrapper.find('.js-assignee-tooltip').text()).toContain(`${user.name}`); + }); + + it('sets users path', () => { + expect(wrapper.find('.board-card-assignee a').attributes('href')).toBe('/test'); + }); + + it('renders avatar', () => { + expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); + }); + }); + + describe('assignee default avatar', () => { + beforeEach(done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [ + new ListAssignee( + { + id: 1, + name: 'testing 123', + username: 'test', + }, + 'default_avatar', + ), + ], + }, + }); + + wrapper.vm.$nextTick(done); + }); + + it('displays defaults avatar if users avatar is null', () => { + expect(wrapper.find('.board-card-assignee img').exists()).toBe(true); + expect(wrapper.find('.board-card-assignee img').attributes('src')).toBe( + 'default_avatar?width=24', + ); + }); + }); + }); + + describe('multiple assignees', () => { + beforeEach(done => { + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees: [ + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + }), + ], + }, + }); + + wrapper.vm.$nextTick(done); + }); + + it('renders all three assignees', () => { + expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(3); + }); + + describe('more than three assignees', () => { + beforeEach(done => { + const { assignees } = wrapper.props('issue'); + assignees.push( + new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + }), + ); + + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees, + }, + }); + wrapper.vm.$nextTick(done); + }); + + it('renders more avatar counter', () => { + expect( + wrapper + .find('.board-card-assignee .avatar-counter') + .text() + .trim(), + ).toEqual('+2'); + }); + + it('renders two assignees', () => { + expect(wrapper.findAll('.board-card-assignee .avatar').length).toEqual(2); + }); + + it('renders 99+ avatar counter', done => { + const assignees = [ + ...wrapper.props('issue').assignees, + ..._.range(5, 103).map( + i => + new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }), + ), + ]; + wrapper.setProps({ + issue: { + ...wrapper.props('issue'), + assignees, + }, + }); + + wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('.board-card-assignee .avatar-counter') + .text() + .trim(), + ).toEqual('99+'); + done(); + }); + }); + }); + }); + + describe('labels', () => { + beforeEach(done => { + issue.addLabel(label1); + wrapper.setProps({ issue: { ...issue } }); + + wrapper.vm.$nextTick(done); + }); + + it('does not render list label but renders all other labels', () => { + expect(wrapper.findAll('.badge').length).toBe(1); + }); + + it('renders label', () => { + const nodes = wrapper + .findAll('.badge') + .wrappers.map(label => label.attributes('data-original-title')); + + expect(nodes.includes(label1.description)).toBe(true); + }); + + it('sets label description as title', () => { + expect(wrapper.find('.badge').attributes('data-original-title')).toContain( + label1.description, + ); + }); + + it('sets background color of button', () => { + const nodes = wrapper + .findAll('.badge') + .wrappers.map(label => label.element.style.backgroundColor); + + expect(nodes.includes(label1.color)).toBe(true); + }); + + it('does not render label if label does not have an ID', done => { + issue.addLabel( + new ListLabel({ + title: 'closed', + }), + ); + wrapper.setProps({ issue: { ...issue } }); + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.findAll('.badge').length).toBe(1); + expect(wrapper.text()).not.toContain('closed'); + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js new file mode 100644 index 00000000000..38b2333e679 --- /dev/null +++ b/spec/frontend/boards/stores/getters_spec.js @@ -0,0 +1,21 @@ +import getters from '~/boards/stores/getters'; + +describe('Boards - Getters', () => { + describe('getLabelToggleState', () => { + it('should return "on" when isShowingLabels is true', () => { + const state = { + isShowingLabels: true, + }; + + expect(getters.getLabelToggleState(state)).toBe('on'); + }); + + it('should return "off" when isShowingLabels is false', () => { + const state = { + isShowingLabels: false, + }; + + expect(getters.getLabelToggleState(state)).toBe('off'); + }); + }); +}); diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index 517d8781600..199e11401a9 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -10,8 +10,10 @@ import axios from '~/lib/utils/axios_utils'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import $ from 'jquery'; +import initProjectSelectDropdown from '~/project_select'; jest.mock('~/lib/utils/poll'); +jest.mock('~/project_select'); const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS; @@ -44,6 +46,7 @@ describe('Clusters', () => { afterEach(() => { cluster.destroy(); mock.restore(); + jest.clearAllMocks(); }); describe('class constructor', () => { @@ -55,6 +58,10 @@ describe('Clusters', () => { it('should call initPolling on construct', () => { expect(cluster.initPolling).toHaveBeenCalled(); }); + + it('should call initProjectSelectDropdown on construct', () => { + expect(initProjectSelectDropdown).toHaveBeenCalled(); + }); }); describe('toggle', () => { @@ -279,16 +286,21 @@ describe('Clusters', () => { }); describe('installApplication', () => { - it.each(APPLICATIONS)('tries to install %s', applicationId => { - jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); + it.each(APPLICATIONS)('tries to install %s', (applicationId, done) => { + jest.spyOn(cluster.service, 'installApplication').mockResolvedValue(); cluster.store.state.applications[applicationId].status = INSTALLABLE; - cluster.installApplication({ id: applicationId }); - - expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); - expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined); + // eslint-disable-next-line promise/valid-params + cluster + .installApplication({ id: applicationId }) + .then(() => { + expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); + expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined); + done(); + }) + .catch(); }); it('sets error request status when the request fails', () => { diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index fbcab078993..49bda9539fd 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -6,6 +6,7 @@ import { APPLICATIONS_MOCK_STATE } from '../services/mock_data'; import eventHub from '~/clusters/event_hub'; import { shallowMount } from '@vue/test-utils'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; +import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; describe('Applications', () => { let vm; @@ -13,6 +14,10 @@ describe('Applications', () => { beforeEach(() => { Applications = Vue.extend(applications); + + gon.features = gon.features || {}; + gon.features.enableClusterApplicationElasticStack = true; + gon.features.enableClusterApplicationCrossplane = true; }); afterEach(() => { @@ -39,6 +44,10 @@ describe('Applications', () => { expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); + it('renders a row for Crossplane', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + }); + it('renders a row for Prometheus', () => { expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); @@ -54,6 +63,10 @@ describe('Applications', () => { it('renders a row for Knative', () => { expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); }); + + it('renders a row for Elastic Stack', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + }); }); describe('Group cluster applications', () => { @@ -76,6 +89,10 @@ describe('Applications', () => { expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); + it('renders a row for Crossplane', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + }); + it('renders a row for Prometheus', () => { expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); @@ -91,6 +108,10 @@ describe('Applications', () => { it('renders a row for Knative', () => { expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); }); + + it('renders a row for Elastic Stack', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + }); }); describe('Instance cluster applications', () => { @@ -113,6 +134,10 @@ describe('Applications', () => { expect(vm.$el.querySelector('.js-cluster-application-row-cert_manager')).not.toBeNull(); }); + it('renders a row for Crossplane', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-crossplane')).not.toBeNull(); + }); + it('renders a row for Prometheus', () => { expect(vm.$el.querySelector('.js-cluster-application-row-prometheus')).not.toBeNull(); }); @@ -128,6 +153,10 @@ describe('Applications', () => { it('renders a row for Knative', () => { expect(vm.$el.querySelector('.js-cluster-application-row-knative')).not.toBeNull(); }); + + it('renders a row for Elastic Stack', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack')).not.toBeNull(); + }); }); describe('Ingress application', () => { @@ -164,10 +193,12 @@ describe('Applications', () => { }, helm: { title: 'Helm Tiller' }, cert_manager: { title: 'Cert-Manager' }, + crossplane: { title: 'Crossplane', stack: '' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, jupyter: { title: 'JupyterHub', hostname: '' }, knative: { title: 'Knative', hostname: '' }, + elastic_stack: { title: 'Elastic Stack', kibana_hostname: '' }, }, }); @@ -260,7 +291,11 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual(null); + expect( + vm.$el + .querySelector('.js-cluster-application-row-jupyter .js-hostname') + .getAttribute('readonly'), + ).toEqual(null); }); }); @@ -273,7 +308,9 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-hostname')).toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe( + null, + ); }); }); @@ -287,7 +324,11 @@ describe('Applications', () => { }, }); - expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual('readonly'); + expect( + vm.$el + .querySelector('.js-cluster-application-row-jupyter .js-hostname') + .getAttribute('readonly'), + ).toEqual('readonly'); }); }); @@ -299,7 +340,9 @@ describe('Applications', () => { }); it('does not render input', () => { - expect(vm.$el.querySelector('.js-hostname')).toBe(null); + expect(vm.$el.querySelector('.js-cluster-application-row-jupyter .js-hostname')).toBe( + null, + ); }); it('renders disabled install button', () => { @@ -361,4 +404,110 @@ describe('Applications', () => { }); }); }); + + describe('Crossplane application', () => { + const propsData = { + applications: { + ...APPLICATIONS_MOCK_STATE, + crossplane: { + title: 'Crossplane', + stack: { + code: '', + }, + }, + }, + }; + + let wrapper; + beforeEach(() => { + wrapper = shallowMount(Applications, { propsData }); + }); + afterEach(() => { + wrapper.destroy(); + }); + it('renders the correct Component', () => { + const crossplane = wrapper.find(CrossplaneProviderStack); + expect(crossplane.exists()).toBe(true); + }); + }); + + describe('Elastic Stack application', () => { + describe('with ingress installed with ip & elastic stack installable', () => { + it('renders hostname active input', () => { + vm = mountComponent(Applications, { + applications: { + ...APPLICATIONS_MOCK_STATE, + ingress: { + title: 'Ingress', + status: 'installed', + externalIp: '1.1.1.1', + }, + }, + }); + + expect( + vm.$el + .querySelector('.js-cluster-application-row-elastic_stack .js-hostname') + .getAttribute('readonly'), + ).toEqual(null); + }); + }); + + describe('with ingress installed without external ip', () => { + it('does not render hostname input', () => { + vm = mountComponent(Applications, { + applications: { + ...APPLICATIONS_MOCK_STATE, + ingress: { title: 'Ingress', status: 'installed' }, + }, + }); + + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack .js-hostname')).toBe( + null, + ); + }); + }); + + describe('with ingress & elastic stack installed', () => { + it('renders readonly input', () => { + vm = mountComponent(Applications, { + applications: { + ...APPLICATIONS_MOCK_STATE, + ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, + elastic_stack: { title: 'Elastic Stack', status: 'installed', kibana_hostname: '' }, + }, + }); + + expect( + vm.$el + .querySelector('.js-cluster-application-row-elastic_stack .js-hostname') + .getAttribute('readonly'), + ).toEqual('readonly'); + }); + }); + + describe('without ingress installed', () => { + beforeEach(() => { + vm = mountComponent(Applications, { + applications: APPLICATIONS_MOCK_STATE, + }); + }); + + it('does not render input', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-elastic_stack .js-hostname')).toBe( + null, + ); + }); + + it('renders disabled install button', () => { + expect( + vm.$el + .querySelector( + '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', + ) + .getAttribute('disabled'), + ).toEqual('disabled'); + }); + }); + }); }); diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js new file mode 100644 index 00000000000..0d234822d7b --- /dev/null +++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js @@ -0,0 +1,78 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdownItem } from '@gitlab/ui'; +import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; + +describe('CrossplaneProviderStack component', () => { + let wrapper; + + const defaultProps = { + stacks: [ + { + name: 'Google Cloud Platform', + code: 'gcp', + }, + { + name: 'Amazon Web Services', + code: 'aws', + }, + ], + }; + + function createComponent(props = {}) { + const propsData = { + ...defaultProps, + ...props, + }; + + wrapper = shallowMount(CrossplaneProviderStack, { + propsData, + }); + } + + beforeEach(() => { + const crossplane = { + title: 'crossplane', + stack: '', + }; + createComponent({ crossplane }); + }); + + const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findFirstDropdownElement = () => findDropdownElements().at(0); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders all of the available stacks in the dropdown', () => { + const dropdownElements = findDropdownElements(); + + expect(dropdownElements.length).toBe(defaultProps.stacks.length); + + defaultProps.stacks.forEach((stack, index) => + expect(dropdownElements.at(index).text()).toEqual(stack.name), + ); + }); + + it('displays the correct label for the first dropdown item if a stack is selected', () => { + const crossplane = { + title: 'crossplane', + stack: 'gcp', + }; + createComponent({ crossplane }); + expect(wrapper.vm.dropdownText).toBe('Google Cloud Platform'); + }); + + it('emits the "set" event with the selected stack value', () => { + const crossplane = { + title: 'crossplane', + stack: 'gcp', + }; + createComponent({ crossplane }); + findFirstDropdownElement().vm.$emit('click'); + expect(wrapper.emitted().set[0][0].code).toEqual('gcp'); + }); + it('it renders the correct dropdown text when no stack is selected', () => { + expect(wrapper.vm.dropdownText).toBe('Select Stack'); + }); +}); diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index 41ad398e924..016f5a259b5 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -52,6 +52,18 @@ const CLUSTERS_MOCK_DATA = { email: 'test@example.com', can_uninstall: false, }, + { + name: 'crossplane', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + can_uninstall: false, + }, + { + name: 'elastic_stack', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + can_uninstall: false, + }, ], }, }, @@ -98,6 +110,17 @@ const CLUSTERS_MOCK_DATA = { status_reason: 'Cannot connect', email: 'test@example.com', }, + { + name: 'crossplane', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + stack: 'gcp', + }, + { + name: 'elastic_stack', + status: APPLICATION_STATUS.ERROR, + status_reason: 'Cannot connect', + }, ], }, }, @@ -105,11 +128,13 @@ const CLUSTERS_MOCK_DATA = { POST: { '/gitlab-org/gitlab-shell/clusters/1/applications/helm': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': {}, + '/gitlab-org/gitlab-shell/clusters/1/applications/crossplane': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/cert_manager': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/runner': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/jupyter': {}, '/gitlab-org/gitlab-shell/clusters/1/applications/knative': {}, + '/gitlab-org/gitlab-shell/clusters/1/applications/elastic_stack': {}, }, }; @@ -126,11 +151,13 @@ const DEFAULT_APPLICATION_STATE = { const APPLICATIONS_MOCK_STATE = { helm: { title: 'Helm Tiller', status: 'installable' }, ingress: { title: 'Ingress', status: 'installable' }, + crossplane: { title: 'Crossplane', status: 'installable', stack: '' }, 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: '' }, + elastic_stack: { title: 'Elastic Stack', status: 'installable', kibana_hostname: '' }, }; export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index 5ee06eb44c9..71d4daceb75 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -71,6 +71,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, ingress: { title: 'Ingress', @@ -84,6 +85,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, runner: { title: 'GitLab Runner', @@ -100,6 +102,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, prometheus: { title: 'Prometheus', @@ -111,6 +114,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, jupyter: { title: 'JupyterHub', @@ -123,6 +127,7 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, }, knative: { title: 'Knative', @@ -140,6 +145,7 @@ describe('Clusters Store', () => { uninstallFailed: false, updateSuccessful: false, updateFailed: false, + validationError: null, }, cert_manager: { title: 'Cert-Manager', @@ -152,6 +158,32 @@ describe('Clusters Store', () => { uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, + validationError: null, + }, + elastic_stack: { + title: 'Elastic Stack', + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, + statusReason: mockResponseData.applications[7].status_reason, + requestReason: null, + kibana_hostname: '', + installed: false, + uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, + validationError: null, + }, + crossplane: { + title: 'Crossplane', + status: APPLICATION_STATUS.INSTALLABLE, + installFailed: true, + statusReason: mockResponseData.applications[8].status_reason, + requestReason: null, + installed: false, + uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, + validationError: null, }, }, environments: [], @@ -183,5 +215,16 @@ describe('Clusters Store', () => { `jupyter.${store.state.applications.ingress.externalIp}.nip.io`, ); }); + + it('sets default hostname for elastic stack when ingress has a ip address', () => { + const mockResponseData = + CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data; + + store.updateStateFromServer(mockResponseData); + + expect(store.state.applications.elastic_stack.kibana_hostname).toEqual( + `kibana.${store.state.applications.ingress.externalIp}.nip.io`, + ); + }); }); }); diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index 47bdc677068..3c603c7f573 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -26,7 +26,7 @@ exports[`Confidential merge request project form group component renders empty s > fork the project </a> - and set the forks visiblity to private. + and set the forks visibility to private. </span> <gllink-stub @@ -76,7 +76,7 @@ exports[`Confidential merge request project form group component renders fork dr > fork the project </a> - and set the forks visiblity to private. + and set the forks visibility to private. </span> <gllink-stub diff --git a/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap new file mode 100644 index 00000000000..b87afdd7eb4 --- /dev/null +++ b/spec/frontend/contributors/component/__snapshots__/contributors_spec.js.snap @@ -0,0 +1,47 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Contributors charts should render charts when loading completed and there is chart data 1`] = ` +<div> + <div + class="contributors-charts" + > + <h4> + Commits to master + </h4> + + <span> + Excluding merge commits. Limited to 6,000 commits. + </span> + + <div> + <glareachart-stub + data="[object Object]" + height="264" + option="[object Object]" + /> + </div> + + <div + class="row" + > + <div + class="col-6" + > + <h4> + John + </h4> + + <p> + 2 commits (jawnnypoo@gmail.com) + </p> + + <glareachart-stub + data="[object Object]" + height="216" + option="[object Object]" + /> + </div> + </div> + </div> +</div> +`; diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js new file mode 100644 index 00000000000..fdba09ed26c --- /dev/null +++ b/spec/frontend/contributors/component/contributors_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { createStore } from '~/contributors/stores'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import ContributorsCharts from '~/contributors/components/contributors.vue'; + +const localVue = createLocalVue(); +let wrapper; +let mock; +let store; +const Component = Vue.extend(ContributorsCharts); +const endpoint = 'contributors'; +const branch = 'master'; +const chartData = [ + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, +]; + +function factory() { + mock = new MockAdapter(axios); + jest.spyOn(axios, 'get'); + mock.onGet().reply(200, chartData); + store = createStore(); + + wrapper = shallowMount(Component, { + propsData: { + endpoint, + branch, + }, + stubs: { + GlLoadingIcon: true, + GlAreaChart: true, + }, + store, + }); +} + +describe('Contributors charts', () => { + beforeEach(() => { + factory(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + it('should fetch chart data when mounted', () => { + expect(axios.get).toHaveBeenCalledWith(endpoint); + }); + + it('should display loader whiled loading data', () => { + wrapper.vm.$store.state.loading = true; + return localVue.nextTick(() => { + expect(wrapper.find('.contributors-loader').exists()).toBe(true); + }); + }); + + it('should render charts when loading completed and there is chart data', () => { + wrapper.vm.$store.state.loading = false; + wrapper.vm.$store.state.chartData = chartData; + return localVue.nextTick(() => { + expect(wrapper.find('.contributors-loader').exists()).toBe(false); + expect(wrapper.find('.contributors-charts').exists()).toBe(true); + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js new file mode 100644 index 00000000000..bb017e0ac0f --- /dev/null +++ b/spec/frontend/contributors/store/actions_spec.js @@ -0,0 +1,60 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import flashError from '~/flash'; +import * as actions from '~/contributors/stores/actions'; +import * as types from '~/contributors/stores/mutation_types'; + +jest.mock('~/flash.js'); + +describe('Contributors store actions', () => { + describe('fetchChartData', () => { + let mock; + const endpoint = '/contributors'; + const chartData = { '2017-11': 0, '2017-12': 2 }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + it('should commit SET_CHART_DATA with received response', done => { + mock.onGet().reply(200, chartData); + + testAction( + actions.fetchChartData, + { endpoint }, + {}, + [ + { type: types.SET_LOADING_STATE, payload: true }, + { type: types.SET_CHART_DATA, payload: chartData }, + { type: types.SET_LOADING_STATE, payload: false }, + ], + [], + () => { + mock.restore(); + done(); + }, + ); + }); + + it('should show flash on API error', done => { + mock.onGet().reply(400, 'Not Found'); + + testAction( + actions.fetchChartData, + { endpoint }, + {}, + [{ type: types.SET_LOADING_STATE, payload: true }], + [], + () => { + expect(flashError).toHaveBeenCalledWith(expect.stringMatching('error')); + mock.restore(); + done(); + }, + ); + }); + }); +}); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/spec/frontend/contributors/store/getters_spec.js b/spec/frontend/contributors/store/getters_spec.js new file mode 100644 index 00000000000..62ae9b36f87 --- /dev/null +++ b/spec/frontend/contributors/store/getters_spec.js @@ -0,0 +1,73 @@ +import * as getters from '~/contributors/stores/getters'; + +describe('Contributors Store Getters', () => { + const state = {}; + + describe('showChart', () => { + it('should NOT show chart if loading', () => { + state.loading = true; + + expect(getters.showChart(state)).toEqual(false); + }); + + it('should NOT show chart there is not data', () => { + state.loading = false; + state.chartData = null; + + expect(getters.showChart(state)).toEqual(false); + }); + + it('should show the chart in case loading complated and there is data', () => { + state.loading = false; + state.chartData = true; + + expect(getters.showChart(state)).toEqual(true); + }); + + describe('parsedData', () => { + let parsed; + + beforeAll(() => { + state.chartData = [ + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, + { author_name: 'Carlson', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-04-04' }, + { author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' }, + ]; + parsed = getters.parsedData(state); + }); + + it('should group contributions by date ', () => { + expect(parsed.total).toMatchObject({ '2019-05-05': 3, '2019-03-03': 2, '2019-04-04': 2 }); + }); + + it('should group contributions by author ', () => { + expect(parsed.byAuthor).toMatchObject({ + Carlson: { + email: 'jawnnypoo@gmail.com', + commits: 2, + dates: { + '2019-03-03': 1, + '2019-05-05': 1, + }, + }, + John: { + email: 'jawnnypoo@gmail.com', + commits: 5, + dates: { + '2019-03-03': 1, + '2019-04-04': 2, + '2019-05-05': 2, + }, + }, + }); + }); + }); + }); +}); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/spec/frontend/contributors/store/mutations_spec.js b/spec/frontend/contributors/store/mutations_spec.js new file mode 100644 index 00000000000..e9e756d4a65 --- /dev/null +++ b/spec/frontend/contributors/store/mutations_spec.js @@ -0,0 +1,40 @@ +import state from '~/contributors/stores/state'; +import mutations from '~/contributors/stores/mutations'; +import * as types from '~/contributors/stores/mutation_types'; + +describe('Contributors mutations', () => { + let stateCopy; + + beforeEach(() => { + stateCopy = state(); + }); + + describe('SET_LOADING_STATE', () => { + it('should set loading flag', () => { + const loading = true; + mutations[types.SET_LOADING_STATE](stateCopy, loading); + + expect(stateCopy.loading).toEqual(loading); + }); + }); + + describe('SET_CHART_DATA', () => { + const chartData = { '2017-11': 0, '2017-12': 2 }; + + it('should set chart data', () => { + mutations[types.SET_CHART_DATA](stateCopy, chartData); + + expect(stateCopy.chartData).toEqual(chartData); + }); + }); + + describe('SET_ACTIVE_BRANCH', () => { + it('should set search query', () => { + const branch = 'feature-branch'; + + mutations[types.SET_ACTIVE_BRANCH](stateCopy, branch); + + expect(stateCopy.branch).toEqual(branch); + }); + }); +}); diff --git a/spec/frontend/contributors/utils_spec.js b/spec/frontend/contributors/utils_spec.js new file mode 100644 index 00000000000..a2b9154329b --- /dev/null +++ b/spec/frontend/contributors/utils_spec.js @@ -0,0 +1,21 @@ +import * as utils from '~/contributors/utils'; + +describe('Contributors Util Functions', () => { + describe('xAxisLabelFormatter', () => { + it('should return year if the date is in January', () => { + expect(utils.xAxisLabelFormatter(new Date('01-12-2019'))).toEqual('2019'); + }); + + it('should return month name otherwise', () => { + expect(utils.xAxisLabelFormatter(new Date('12-02-2019'))).toEqual('Dec'); + expect(utils.xAxisLabelFormatter(new Date('07-12-2019'))).toEqual('Jul'); + }); + }); + + describe('dateFormatter', () => { + it('should format provided date to YYYY-MM-DD format', () => { + expect(utils.dateFormatter(new Date('December 17, 1995 03:24:00'))).toEqual('1995-12-17'); + expect(utils.dateFormatter(new Date(1565308800000))).toEqual('2019-08-09'); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js index 366c2fc7b26..efbe2635fcc 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/cluster_form_dropdown_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; -import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import { GlIcon } from '@gitlab/ui'; describe('ClusterFormDropdown', () => { let vm; @@ -41,24 +41,50 @@ describe('ClusterFormDropdown', () => { .trigger('click'); }); - it('displays selected item label', () => { - expect(vm.find(DropdownButton).props('toggleText')).toEqual(secondItem.name); + it('emits input event with selected item', () => { + expect(vm.emitted('input')[0]).toEqual([secondItem.value]); + }); + }); + + describe('when multiple items are selected', () => { + const value = [1]; + + beforeEach(() => { + vm.setProps({ items, multiple: true, value }); + vm.findAll('.js-dropdown-item') + .at(0) + .trigger('click'); + vm.findAll('.js-dropdown-item') + .at(1) + .trigger('click'); + }); + + it('emits input event with an array of selected items', () => { + expect(vm.emitted('input')[1]).toEqual([[firstItem.value, secondItem.value]]); + }); + }); + + describe('when multiple items can be selected', () => { + beforeEach(() => { + vm.setProps({ items, multiple: true, value: firstItem.value }); }); - it('sets selected value to dropdown hidden input', () => { - expect(vm.find(DropdownHiddenInput).props('value')).toEqual(secondItem.value); + it('displays a checked GlIcon next to the item', () => { + expect(vm.find(GlIcon).is('.invisible')).toBe(false); + expect(vm.find(GlIcon).props('name')).toBe('mobile-issue-close'); }); }); describe('when an item is selected and has a custom label property', () => { it('displays selected item custom label', () => { const labelProperty = 'customLabel'; - const selectedItem = { [labelProperty]: 'Name' }; + const label = 'Name'; + const currentValue = 1; + const customLabelItems = [{ [labelProperty]: label, value: currentValue }]; - vm.setProps({ labelProperty }); - vm.setData({ selectedItem }); + vm.setProps({ labelProperty, items: customLabelItems, value: currentValue }); - expect(vm.find(DropdownButton).props('toggleText')).toEqual(selectedItem[labelProperty]); + expect(vm.find(DropdownButton).props('toggleText')).toEqual(label); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js new file mode 100644 index 00000000000..4bf3ac430f5 --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/components/create_eks_cluster_spec.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +import CreateEksCluster from '~/create_cluster/eks_cluster/components/create_eks_cluster.vue'; +import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; +import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('CreateEksCluster', () => { + let vm; + let state; + const gitlabManagedClusterHelpPath = 'gitlab-managed-cluster-help-path'; + const accountAndExternalIdsHelpPath = 'account-and-external-id-help-path'; + const createRoleArnHelpPath = 'role-arn-help-path'; + const kubernetesIntegrationHelpPath = 'kubernetes-integration'; + const externalLinkIcon = 'external-link'; + + beforeEach(() => { + state = { hasCredentials: false }; + const store = new Vuex.Store({ + state, + }); + + vm = shallowMount(CreateEksCluster, { + propsData: { + gitlabManagedClusterHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + externalLinkIcon, + kubernetesIntegrationHelpPath, + }, + localVue, + store, + }); + }); + afterEach(() => vm.destroy()); + + describe('when credentials are provided', () => { + beforeEach(() => { + state.hasCredentials = true; + }); + + it('displays eks cluster configuration form when credentials are valid', () => { + expect(vm.find(EksClusterConfigurationForm).exists()).toBe(true); + }); + + describe('passes to the cluster configuration form', () => { + it('help url for kubernetes integration documentation', () => { + expect(vm.find(EksClusterConfigurationForm).props('gitlabManagedClusterHelpPath')).toBe( + gitlabManagedClusterHelpPath, + ); + }); + + it('help url for gitlab managed cluster documentation', () => { + expect(vm.find(EksClusterConfigurationForm).props('kubernetesIntegrationHelpPath')).toBe( + kubernetesIntegrationHelpPath, + ); + }); + }); + }); + + describe('when credentials are invalid', () => { + beforeEach(() => { + state.hasCredentials = false; + }); + + it('displays service credentials form', () => { + expect(vm.find(ServiceCredentialsForm).exists()).toBe(true); + }); + + describe('passes to the service credentials form', () => { + it('help url for account and external ids', () => { + expect(vm.find(ServiceCredentialsForm).props('accountAndExternalIdsHelpPath')).toBe( + accountAndExternalIdsHelpPath, + ); + }); + + it('external link icon', () => { + expect(vm.find(ServiceCredentialsForm).props('externalLinkIcon')).toBe(externalLinkIcon); + }); + + it('help url to create a role ARN', () => { + expect(vm.find(ServiceCredentialsForm).props('createRoleArnHelpPath')).toBe( + createRoleArnHelpPath, + ); + }); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index 69290f6dfa9..25d613d64ed 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -4,7 +4,6 @@ import Vue from 'vue'; import { GlFormCheckbox } from '@gitlab/ui'; import EksClusterConfigurationForm from '~/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue'; -import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue'; import eksClusterFormState from '~/create_cluster/eks_cluster/store/state'; import clusterDropdownStoreState from '~/create_cluster/eks_cluster/store/cluster_dropdown/state'; @@ -21,17 +20,21 @@ describe('EksClusterConfigurationForm', () => { let subnetsState; let keyPairsState; let securityGroupsState; + let instanceTypesState; let vpcsActions; let rolesActions; let regionsActions; let subnetsActions; let keyPairsActions; let securityGroupsActions; + let instanceTypesActions; let vm; beforeEach(() => { state = eksClusterFormState(); actions = { + signOut: jest.fn(), + createCluster: jest.fn(), setClusterName: jest.fn(), setEnvironmentScope: jest.fn(), setKubernetesVersion: jest.fn(), @@ -41,6 +44,8 @@ describe('EksClusterConfigurationForm', () => { setRole: jest.fn(), setKeyPair: jest.fn(), setSecurityGroup: jest.fn(), + setInstanceType: jest.fn(), + setNodeCount: jest.fn(), setGitlabManagedCluster: jest.fn(), }; regionsActions = { @@ -61,6 +66,9 @@ describe('EksClusterConfigurationForm', () => { securityGroupsActions = { fetchItems: jest.fn(), }; + instanceTypesActions = { + fetchItems: jest.fn(), + }; rolesState = { ...clusterDropdownStoreState(), }; @@ -79,6 +87,9 @@ describe('EksClusterConfigurationForm', () => { securityGroupsState = { ...clusterDropdownStoreState(), }; + instanceTypesState = { + ...clusterDropdownStoreState(), + }; store = new Vuex.Store({ state, actions, @@ -113,6 +124,11 @@ describe('EksClusterConfigurationForm', () => { state: securityGroupsState, actions: securityGroupsActions, }, + instanceTypes: { + namespaced: true, + state: instanceTypesState, + actions: instanceTypesActions, + }, }, }); }); @@ -124,6 +140,7 @@ describe('EksClusterConfigurationForm', () => { propsData: { gitlabManagedClusterHelpPath: '', kubernetesIntegrationHelpPath: '', + externalLinkIcon: '', }, }); }); @@ -132,15 +149,34 @@ describe('EksClusterConfigurationForm', () => { vm.destroy(); }); + const setAllConfigurationFields = () => { + store.replaceState({ + ...state, + clusterName: 'cluster name', + environmentScope: '*', + selectedRegion: 'region', + selectedRole: 'role', + selectedKeyPair: 'key pair', + selectedVpc: 'vpc', + selectedSubnet: 'subnet', + selectedSecurityGroup: 'group', + selectedInstanceType: 'small-1', + }); + }; + + const findSignOutButton = () => vm.find('.js-sign-out'); + const findCreateClusterButton = () => vm.find('.js-create-cluster'); const findClusterNameInput = () => vm.find('[id=eks-cluster-name]'); const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]'); const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]'); - const findRegionDropdown = () => vm.find(RegionDropdown); + const findRegionDropdown = () => vm.find('[field-id="eks-region"]'); const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]'); const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]'); const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]'); const findRoleDropdown = () => vm.find('[field-id="eks-role"]'); const findSecurityGroupDropdown = () => vm.find('[field-id="eks-security-group"]'); + const findInstanceTypeDropdown = () => vm.find('[field-id="eks-instance-type"'); + const findNodeCountInput = () => vm.find('[id="eks-node-count"]'); const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox); describe('when mounted', () => { @@ -151,6 +187,15 @@ describe('EksClusterConfigurationForm', () => { it('fetches available roles', () => { expect(rolesActions.fetchItems).toHaveBeenCalled(); }); + + it('fetches available instance types', () => { + expect(instanceTypesActions.fetchItems).toHaveBeenCalled(); + }); + }); + + it('dispatches signOut action when sign out button is clicked', () => { + findSignOutButton().trigger('click'); + expect(actions.signOut).toHaveBeenCalled(); }); it('sets isLoadingRoles to RoleDropdown loading property', () => { @@ -180,11 +225,13 @@ describe('EksClusterConfigurationForm', () => { }); it('sets regions to RegionDropdown regions property', () => { - expect(findRegionDropdown().props('regions')).toBe(regionsState.items); + expect(findRegionDropdown().props('items')).toBe(regionsState.items); }); it('sets loadingRegionsError to RegionDropdown error property', () => { - expect(findRegionDropdown().props('error')).toBe(regionsState.loadingItemsError); + regionsState.loadingItemsError = new Error(); + + expect(findRegionDropdown().props('hasErrors')).toEqual(true); }); it('disables KeyPairDropdown when no region is selected', () => { @@ -329,6 +376,34 @@ describe('EksClusterConfigurationForm', () => { undefined, ); }); + + it('cleans selected vpc', () => { + expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }, undefined); + }); + + it('cleans selected key pair', () => { + expect(actions.setKeyPair).toHaveBeenCalledWith( + expect.anything(), + { keyPair: null }, + undefined, + ); + }); + + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith( + expect.anything(), + { subnet: null }, + undefined, + ); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith( + expect.anything(), + { securityGroup: null }, + undefined, + ); + }); }); it('dispatches setClusterName when cluster name input changes', () => { @@ -381,8 +456,10 @@ describe('EksClusterConfigurationForm', () => { describe('when vpc is selected', () => { const vpc = { name: 'vpc-1' }; + const region = 'east-1'; beforeEach(() => { + state.selectedRegion = region; findVpcDropdown().vm.$emit('input', vpc); }); @@ -390,14 +467,34 @@ describe('EksClusterConfigurationForm', () => { expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); }); + it('cleans selected subnet', () => { + expect(actions.setSubnet).toHaveBeenCalledWith( + expect.anything(), + { subnet: null }, + undefined, + ); + }); + + it('cleans selected security group', () => { + expect(actions.setSecurityGroup).toHaveBeenCalledWith( + expect.anything(), + { securityGroup: null }, + undefined, + ); + }); + it('dispatches fetchSubnets action', () => { - expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); + expect(subnetsActions.fetchItems).toHaveBeenCalledWith( + expect.anything(), + { vpc, region }, + undefined, + ); }); it('dispatches fetchSecurityGroups action', () => { expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith( expect.anything(), - { vpc }, + { vpc, region }, undefined, ); }); @@ -454,4 +551,76 @@ describe('EksClusterConfigurationForm', () => { ); }); }); + + describe('when instance type is selected', () => { + const instanceType = 'small-1'; + + beforeEach(() => { + findInstanceTypeDropdown().vm.$emit('input', instanceType); + }); + + it('dispatches setInstanceType action', () => { + expect(actions.setInstanceType).toHaveBeenCalledWith( + expect.anything(), + { instanceType }, + undefined, + ); + }); + }); + + it('dispatches setNodeCount when node count input changes', () => { + const nodeCount = 5; + + findNodeCountInput().vm.$emit('input', nodeCount); + + expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }, undefined); + }); + + describe('when all cluster configuration fields are set', () => { + beforeEach(() => { + setAllConfigurationFields(); + }); + + it('enables create cluster button', () => { + expect(findCreateClusterButton().props('disabled')).toBe(false); + }); + }); + + describe('when at least one cluster configuration field is not set', () => { + beforeEach(() => { + setAllConfigurationFields(); + store.replaceState({ + ...state, + clusterName: '', + }); + }); + + it('disables create cluster button', () => { + expect(findCreateClusterButton().props('disabled')).toBe(true); + }); + }); + + describe('when isCreatingCluster', () => { + beforeEach(() => { + setAllConfigurationFields(); + store.replaceState({ + ...state, + isCreatingCluster: true, + }); + }); + + it('sets create cluster button as loading', () => { + expect(findCreateClusterButton().props('loading')).toBe(true); + }); + }); + + describe('clicking create cluster button', () => { + beforeEach(() => { + findCreateClusterButton().vm.$emit('click'); + }); + + it('dispatches createCluster action', () => { + expect(actions.createCluster).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js b/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js deleted file mode 100644 index 0ebb5026a4b..00000000000 --- a/spec/frontend/create_cluster/eks_cluster/components/region_dropdown_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import ClusterFormDropdown from '~/create_cluster/eks_cluster/components/cluster_form_dropdown.vue'; -import RegionDropdown from '~/create_cluster/eks_cluster/components/region_dropdown.vue'; - -describe('RegionDropdown', () => { - let vm; - - const getClusterFormDropdown = () => vm.find(ClusterFormDropdown); - - beforeEach(() => { - vm = shallowMount(RegionDropdown); - }); - afterEach(() => vm.destroy()); - - it('renders a cluster-form-dropdown', () => { - expect(getClusterFormDropdown().exists()).toBe(true); - }); - - it('sets regions to cluster-form-dropdown items property', () => { - const regions = [{ name: 'basic' }]; - - vm.setProps({ regions }); - - expect(getClusterFormDropdown().props('items')).toEqual(regions); - }); - - it('sets a loading text', () => { - expect(getClusterFormDropdown().props('loadingText')).toEqual('Loading Regions'); - }); - - it('sets a placeholder', () => { - expect(getClusterFormDropdown().props('placeholder')).toEqual('Select a region'); - }); - - it('sets an empty results text', () => { - expect(getClusterFormDropdown().props('emptyText')).toEqual('No region found'); - }); - - it('sets a search field placeholder', () => { - expect(getClusterFormDropdown().props('searchFieldPlaceholder')).toEqual('Search regions'); - }); - - it('sets hasErrors property', () => { - vm.setProps({ error: {} }); - - expect(getClusterFormDropdown().props('hasErrors')).toEqual(true); - }); - - it('sets an error message', () => { - expect(getClusterFormDropdown().props('errorMessage')).toEqual( - 'Could not load regions from your AWS account', - ); - }); -}); diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js new file mode 100644 index 00000000000..0be723b48f0 --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js @@ -0,0 +1,117 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; + +import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +import eksClusterState from '~/create_cluster/eks_cluster/store/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ServiceCredentialsForm', () => { + let vm; + let state; + let createRoleAction; + const accountId = 'accountId'; + const externalId = 'externalId'; + + beforeEach(() => { + state = Object.assign(eksClusterState(), { + accountId, + externalId, + }); + createRoleAction = jest.fn(); + + const store = new Vuex.Store({ + state, + actions: { + createRole: createRoleAction, + }, + }); + vm = shallowMount(ServiceCredentialsForm, { + propsData: { + accountAndExternalIdsHelpPath: '', + createRoleArnHelpPath: '', + externalLinkIcon: '', + }, + localVue, + store, + }); + }); + afterEach(() => vm.destroy()); + + const findAccountIdInput = () => vm.find('#gitlab-account-id'); + const findCopyAccountIdButton = () => vm.find('.js-copy-account-id-button'); + const findExternalIdInput = () => vm.find('#eks-external-id'); + const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button'); + const findInvalidCredentials = () => vm.find('.js-invalid-credentials'); + const findSubmitButton = () => vm.find(LoadingButton); + const findForm = () => vm.find('form[name="service-credentials-form"]'); + + it('displays provided account id', () => { + expect(findAccountIdInput().attributes('value')).toBe(accountId); + }); + + it('allows to copy account id', () => { + expect(findCopyAccountIdButton().props('text')).toBe(accountId); + }); + + it('displays provided external id', () => { + expect(findExternalIdInput().attributes('value')).toBe(externalId); + }); + + it('allows to copy external id', () => { + expect(findCopyExternalIdButton().props('text')).toBe(externalId); + }); + + it('disables submit button when role ARN is not provided', () => { + expect(findSubmitButton().attributes('disabled')).toBeTruthy(); + }); + + it('enables submit button when role ARN is not provided', () => { + vm.setData({ roleArn: '123' }); + + expect(findSubmitButton().attributes('disabled')).toBeFalsy(); + }); + + it('dispatches createRole action when form is submitted', () => { + findForm().trigger('submit'); + + expect(createRoleAction).toHaveBeenCalled(); + }); + + describe('when is creating role', () => { + beforeEach(() => { + vm.setData({ roleArn: '123' }); // set role ARN to enable button + + state.isCreatingRole = true; + }); + + it('disables submit button', () => { + expect(findSubmitButton().props('disabled')).toBe(true); + }); + + it('sets submit button as loading', () => { + expect(findSubmitButton().props('loading')).toBe(true); + }); + + it('displays Authenticating label on submit button', () => { + expect(findSubmitButton().props('label')).toBe('Authenticating'); + }); + }); + + describe('when role can’t be created', () => { + beforeEach(() => { + state.createRoleError = 'Invalid credentials'; + }); + + it('displays invalid role warning banner', () => { + expect(findInvalidCredentials().exists()).toBe(true); + }); + + it('displays invalid role error message', () => { + expect(findInvalidCredentials().text()).toContain(state.createRoleError); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js new file mode 100644 index 00000000000..25be858dcb3 --- /dev/null +++ b/spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js @@ -0,0 +1,152 @@ +import awsServicesFacadeFactory from '~/create_cluster/eks_cluster/services/aws_services_facade'; +import axios from '~/lib/utils/axios_utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; + +describe('awsServicesFacade', () => { + let apiPaths; + let axiosMock; + let awsServices; + let region; + let vpc; + + beforeEach(() => { + apiPaths = { + getKeyPairsPath: '/clusters/aws/api/key_pairs', + getRegionsPath: '/clusters/aws/api/regions', + getRolesPath: '/clusters/aws/api/roles', + getSecurityGroupsPath: '/clusters/aws/api/security_groups', + getSubnetsPath: '/clusters/aws/api/subnets', + getVpcsPath: '/clusters/aws/api/vpcs', + getInstanceTypesPath: '/clusters/aws/api/instance_types', + }; + region = 'west-1'; + vpc = 'vpc-2'; + awsServices = awsServicesFacadeFactory(apiPaths); + axiosMock = new AxiosMockAdapter(axios); + }); + + describe('when fetchRegions succeeds', () => { + let regions; + let regionsOutput; + + beforeEach(() => { + regions = [{ region_name: 'east-1' }, { region_name: 'west-2' }]; + regionsOutput = regions.map(({ region_name: name }) => ({ name, value: name })); + axiosMock.onGet(apiPaths.getRegionsPath).reply(200, { regions }); + }); + + it('return list of roles where each item has a name and value', () => { + expect(awsServices.fetchRegions()).resolves.toEqual(regionsOutput); + }); + }); + + describe('when fetchRoles succeeds', () => { + let roles; + let rolesOutput; + + beforeEach(() => { + roles = [ + { role_name: 'admin', arn: 'aws::admin' }, + { role_name: 'read-only', arn: 'aws::read-only' }, + ]; + rolesOutput = roles.map(({ role_name: name, arn: value }) => ({ name, value })); + axiosMock.onGet(apiPaths.getRolesPath).reply(200, { roles }); + }); + + it('return list of regions where each item has a name and value', () => { + expect(awsServices.fetchRoles()).resolves.toEqual(rolesOutput); + }); + }); + + describe('when fetchKeyPairs succeeds', () => { + let keyPairs; + let keyPairsOutput; + + beforeEach(() => { + keyPairs = [{ key_pair: 'key-pair' }, { key_pair: 'key-pair-2' }]; + keyPairsOutput = keyPairs.map(({ key_name: name }) => ({ name, value: name })); + axiosMock + .onGet(apiPaths.getKeyPairsPath, { params: { region } }) + .reply(200, { key_pairs: keyPairs }); + }); + + it('return list of key pairs where each item has a name and value', () => { + expect(awsServices.fetchKeyPairs({ region })).resolves.toEqual(keyPairsOutput); + }); + }); + + describe('when fetchVpcs succeeds', () => { + let vpcs; + let vpcsOutput; + + beforeEach(() => { + vpcs = [{ vpc_id: 'vpc-1' }, { vpc_id: 'vpc-2' }]; + vpcsOutput = vpcs.map(({ vpc_id: name }) => ({ name, value: name })); + axiosMock.onGet(apiPaths.getVpcsPath, { params: { region } }).reply(200, { vpcs }); + }); + + it('return list of vpcs where each item has a name and value', () => { + expect(awsServices.fetchVpcs({ region })).resolves.toEqual(vpcsOutput); + }); + }); + + describe('when fetchSubnets succeeds', () => { + let subnets; + let subnetsOutput; + + beforeEach(() => { + subnets = [{ subnet_id: 'vpc-1' }, { subnet_id: 'vpc-2' }]; + subnetsOutput = subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })); + axiosMock + .onGet(apiPaths.getSubnetsPath, { params: { region, vpc_id: vpc } }) + .reply(200, { subnets }); + }); + + it('return list of subnets where each item has a name and value', () => { + expect(awsServices.fetchSubnets({ region, vpc })).resolves.toEqual(subnetsOutput); + }); + }); + + describe('when fetchSecurityGroups succeeds', () => { + let securityGroups; + let securityGroupsOutput; + + beforeEach(() => { + securityGroups = [ + { group_name: 'admin group', group_id: 'group-1' }, + { group_name: 'basic group', group_id: 'group-2' }, + ]; + securityGroupsOutput = securityGroups.map(({ group_id: value, group_name: name }) => ({ + name, + value, + })); + axiosMock + .onGet(apiPaths.getSecurityGroupsPath, { params: { region, vpc_id: vpc } }) + .reply(200, { security_groups: securityGroups }); + }); + + it('return list of security groups where each item has a name and value', () => { + expect(awsServices.fetchSecurityGroups({ region, vpc })).resolves.toEqual( + securityGroupsOutput, + ); + }); + }); + + describe('when fetchInstanceTypes succeeds', () => { + let instanceTypes; + let instanceTypesOutput; + + beforeEach(() => { + instanceTypes = [{ instance_type_name: 't2.small' }, { instance_type_name: 't2.medium' }]; + instanceTypesOutput = instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })); + axiosMock.onGet(apiPaths.getInstanceTypesPath).reply(200, { instance_types: instanceTypes }); + }); + + it('return list of instance types where each item has a name and value', () => { + expect(awsServices.fetchInstanceTypes()).resolves.toEqual(instanceTypesOutput); + }); + }); +}); diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index 1ed7f806804..cf6c317a2df 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -13,7 +13,20 @@ import { SET_ROLE, SET_SECURITY_GROUP, SET_GITLAB_MANAGED_CLUSTER, + SET_INSTANCE_TYPE, + SET_NODE_COUNT, + REQUEST_CREATE_ROLE, + CREATE_ROLE_SUCCESS, + CREATE_ROLE_ERROR, + REQUEST_CREATE_CLUSTER, + CREATE_CLUSTER_ERROR, + SIGN_OUT, } from '~/create_cluster/eks_cluster/store/mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('EKS Cluster Store Actions', () => { let clusterName; @@ -25,19 +38,43 @@ describe('EKS Cluster Store Actions', () => { let role; let keyPair; let securityGroup; + let instanceType; + let nodeCount; let gitlabManagedCluster; + let mock; + let state; + let newClusterUrl; beforeEach(() => { clusterName = 'my cluster'; environmentScope = 'production'; kubernetesVersion = '11.1'; - region = { name: 'regions-1' }; - vpc = { name: 'vpc-1' }; - subnet = { name: 'subnet-1' }; - role = { name: 'role-1' }; - keyPair = { name: 'key-pair-1' }; - securityGroup = { name: 'default group' }; + region = 'regions-1'; + vpc = 'vpc-1'; + subnet = 'subnet-1'; + role = 'role-1'; + keyPair = 'key-pair-1'; + securityGroup = 'default group'; + instanceType = 'small-1'; + nodeCount = '5'; gitlabManagedCluster = true; + + newClusterUrl = '/clusters/1'; + + state = { + ...createState(), + createRolePath: '/clusters/roles/', + signOutPath: '/aws/signout', + createClusterPath: '/clusters/', + }; + }); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); }); it.each` @@ -51,10 +88,207 @@ describe('EKS Cluster Store Actions', () => { ${'setVpc'} | ${SET_VPC} | ${{ vpc }} | ${'vpc'} ${'setSubnet'} | ${SET_SUBNET} | ${{ subnet }} | ${'subnet'} ${'setSecurityGroup'} | ${SET_SECURITY_GROUP} | ${{ securityGroup }} | ${'securityGroup'} + ${'setInstanceType'} | ${SET_INSTANCE_TYPE} | ${{ instanceType }} | ${'instance type'} + ${'setNodeCount'} | ${SET_NODE_COUNT} | ${{ nodeCount }} | ${'node count'} ${'setGitlabManagedCluster'} | ${SET_GITLAB_MANAGED_CLUSTER} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} `(`$action commits $mutation with $payloadDescription payload`, data => { const { action, mutation, payload } = data; - testAction(actions[action], payload, createState(), [{ type: mutation, payload }]); + testAction(actions[action], payload, state, [{ type: mutation, payload }]); + }); + + describe('createRole', () => { + const payload = { + roleArn: 'role_arn', + externalId: 'externalId', + }; + + describe('when request succeeds', () => { + beforeEach(() => { + mock + .onPost(state.createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .reply(201); + }); + + it('dispatches createRoleSuccess action', () => + testAction( + actions.createRole, + payload, + state, + [], + [{ type: 'requestCreateRole' }, { type: 'createRoleSuccess' }], + )); + }); + + describe('when request fails', () => { + let error; + + beforeEach(() => { + error = new Error('Request failed with status code 400'); + mock + .onPost(state.createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .reply(400, error); + }); + + it('dispatches createRoleError action', () => + testAction( + actions.createRole, + payload, + state, + [], + [{ type: 'requestCreateRole' }, { type: 'createRoleError', payload: { error } }], + )); + }); + }); + + describe('requestCreateRole', () => { + it('commits requestCreaterole mutation', () => { + testAction(actions.requestCreateRole, null, state, [{ type: REQUEST_CREATE_ROLE }]); + }); + }); + + describe('createRoleSuccess', () => { + it('commits createRoleSuccess mutation', () => { + testAction(actions.createRoleSuccess, null, state, [{ type: CREATE_ROLE_SUCCESS }]); + }); + }); + + describe('createRoleError', () => { + it('commits createRoleError mutation', () => { + const payload = { + error: new Error(), + }; + + testAction(actions.createRoleError, payload, state, [{ type: CREATE_ROLE_ERROR, payload }]); + }); + }); + + describe('createCluster', () => { + let requestPayload; + + beforeEach(() => { + requestPayload = { + name: clusterName, + environment_scope: environmentScope, + managed: gitlabManagedCluster, + provider_aws_attributes: { + region, + vpc_id: vpc, + subnet_ids: subnet, + role_arn: role, + key_name: keyPair, + security_group_id: securityGroup, + instance_type: instanceType, + num_nodes: nodeCount, + }, + }; + state = Object.assign(createState(), { + clusterName, + environmentScope, + kubernetesVersion, + selectedRegion: region, + selectedVpc: vpc, + selectedSubnet: subnet, + selectedRole: role, + selectedKeyPair: keyPair, + selectedSecurityGroup: securityGroup, + selectedInstanceType: instanceType, + nodeCount, + gitlabManagedCluster, + }); + }); + + describe('when request succeeds', () => { + beforeEach(() => { + mock.onPost(state.createClusterPath, requestPayload).reply(201, null, { + location: '/clusters/1', + }); + }); + + it('dispatches createClusterSuccess action', () => + testAction( + actions.createCluster, + null, + state, + [], + [ + { type: 'requestCreateCluster' }, + { type: 'createClusterSuccess', payload: newClusterUrl }, + ], + )); + }); + + describe('when request fails', () => { + let response; + + beforeEach(() => { + response = 'Request failed with status code 400'; + mock.onPost(state.createClusterPath, requestPayload).reply(400, response); + }); + + it('dispatches createRoleError action', () => + testAction( + actions.createCluster, + null, + state, + [], + [{ type: 'requestCreateCluster' }, { type: 'createClusterError', payload: response }], + )); + }); + }); + + describe('requestCreateCluster', () => { + it('commits requestCreateCluster mutation', () => { + testAction(actions.requestCreateCluster, null, state, [{ type: REQUEST_CREATE_CLUSTER }]); + }); + }); + + describe('createClusterSuccess', () => { + beforeEach(() => { + jest.spyOn(window.location, 'assign').mockImplementation(() => {}); + }); + afterEach(() => { + window.location.assign.mockRestore(); + }); + + it('redirects to the new cluster URL', () => { + actions.createClusterSuccess(null, newClusterUrl); + + expect(window.location.assign).toHaveBeenCalledWith(newClusterUrl); + }); + }); + + describe('createClusterError', () => { + let payload; + + beforeEach(() => { + payload = { name: ['Create cluster failed'] }; + }); + + it('commits createClusterError mutation', () => { + testAction(actions.createClusterError, payload, state, [ + { type: CREATE_CLUSTER_ERROR, payload }, + ]); + }); + + it('creates a flash that displays the create cluster error', () => { + expect(createFlash).toHaveBeenCalledWith(payload.name[0]); + }); + }); + + describe('signOut', () => { + beforeEach(() => { + mock.onDelete(state.signOutPath).reply(200, null); + }); + + it('commits signOut mutation', () => { + testAction(actions.signOut, null, state, [{ type: SIGN_OUT }]); + }); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js index 81b65180fb5..0fb392f5eea 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/mutations_spec.js @@ -8,7 +8,15 @@ import { SET_SUBNET, SET_ROLE, SET_SECURITY_GROUP, + SET_INSTANCE_TYPE, + SET_NODE_COUNT, SET_GITLAB_MANAGED_CLUSTER, + REQUEST_CREATE_ROLE, + CREATE_ROLE_SUCCESS, + CREATE_ROLE_ERROR, + REQUEST_CREATE_CLUSTER, + CREATE_CLUSTER_ERROR, + SIGN_OUT, } from '~/create_cluster/eks_cluster/store/mutation_types'; import createState from '~/create_cluster/eks_cluster/store/state'; import mutations from '~/create_cluster/eks_cluster/store/mutations'; @@ -24,6 +32,8 @@ describe('Create EKS cluster store mutations', () => { let role; let keyPair; let securityGroup; + let instanceType; + let nodeCount; let gitlabManagedCluster; beforeEach(() => { @@ -36,6 +46,8 @@ describe('Create EKS cluster store mutations', () => { role = { name: 'role-1' }; keyPair = { name: 'key pair' }; securityGroup = { name: 'default group' }; + instanceType = 'small-1'; + nodeCount = '5'; gitlabManagedCluster = false; state = createState(); @@ -50,8 +62,10 @@ describe('Create EKS cluster store mutations', () => { ${SET_REGION} | ${'selectedRegion'} | ${{ region }} | ${region} | ${'selected region payload'} ${SET_KEY_PAIR} | ${'selectedKeyPair'} | ${{ keyPair }} | ${keyPair} | ${'selected key pair payload'} ${SET_VPC} | ${'selectedVpc'} | ${{ vpc }} | ${vpc} | ${'selected vpc payload'} - ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected sybnet payload'} + ${SET_SUBNET} | ${'selectedSubnet'} | ${{ subnet }} | ${subnet} | ${'selected subnet payload'} ${SET_SECURITY_GROUP} | ${'selectedSecurityGroup'} | ${{ securityGroup }} | ${securityGroup} | ${'selected security group payload'} + ${SET_INSTANCE_TYPE} | ${'selectedInstanceType'} | ${{ instanceType }} | ${instanceType} | ${'selected instance type payload'} + ${SET_NODE_COUNT} | ${'nodeCount'} | ${{ nodeCount }} | ${nodeCount} | ${'node count payload'} ${SET_GITLAB_MANAGED_CLUSTER} | ${'gitlabManagedCluster'} | ${{ gitlabManagedCluster }} | ${gitlabManagedCluster} | ${'gitlab managed cluster'} `(`$mutation sets $mutatedProperty to $expectedValueDescription`, data => { const { mutation, mutatedProperty, payload, expectedValue } = data; @@ -59,4 +73,101 @@ describe('Create EKS cluster store mutations', () => { mutations[mutation](state, payload); expect(state[mutatedProperty]).toBe(expectedValue); }); + + describe(`mutation ${REQUEST_CREATE_ROLE}`, () => { + beforeEach(() => { + mutations[REQUEST_CREATE_ROLE](state); + }); + + it('sets isCreatingRole to true', () => { + expect(state.isCreatingRole).toBe(true); + }); + + it('sets createRoleError to null', () => { + expect(state.createRoleError).toBe(null); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); + + describe(`mutation ${CREATE_ROLE_SUCCESS}`, () => { + beforeEach(() => { + mutations[CREATE_ROLE_SUCCESS](state); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingRole).toBe(false); + }); + + it('sets createRoleError to null', () => { + expect(state.createRoleError).toBe(null); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(true); + }); + }); + + describe(`mutation ${CREATE_ROLE_ERROR}`, () => { + const error = new Error(); + + beforeEach(() => { + mutations[CREATE_ROLE_ERROR](state, { error }); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingRole).toBe(false); + }); + + it('sets createRoleError to the error object', () => { + expect(state.createRoleError).toBe(error); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); + + describe(`mutation ${REQUEST_CREATE_CLUSTER}`, () => { + beforeEach(() => { + mutations[REQUEST_CREATE_CLUSTER](state); + }); + + it('sets isCreatingCluster to true', () => { + expect(state.isCreatingCluster).toBe(true); + }); + + it('sets createClusterError to null', () => { + expect(state.createClusterError).toBe(null); + }); + }); + + describe(`mutation ${CREATE_CLUSTER_ERROR}`, () => { + const error = new Error(); + + beforeEach(() => { + mutations[CREATE_CLUSTER_ERROR](state, { error }); + }); + + it('sets isCreatingRole to false', () => { + expect(state.isCreatingCluster).toBe(false); + }); + + it('sets createRoleError to the error object', () => { + expect(state.createClusterError).toBe(error); + }); + }); + + describe(`mutation ${SIGN_OUT}`, () => { + beforeEach(() => { + state.hasCredentials = true; + mutations[SIGN_OUT](state); + }); + + it('sets hasCredentials to false', () => { + expect(state.hasCredentials).toBe(false); + }); + }); }); diff --git a/spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js b/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js index 7b8df03d3c3..b1c25d8fff7 100644 --- a/spec/frontend/projects/gke_cluster_namespace/gke_cluster_namespace_spec.js +++ b/spec/frontend/create_cluster/gke_cluster_namespace/gke_cluster_namespace_spec.js @@ -1,4 +1,4 @@ -import initGkeNamespace from '~/projects/gke_cluster_namespace'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; describe('GKE cluster namespace', () => { const changeEvent = new Event('change'); @@ -14,7 +14,7 @@ describe('GKE cluster namespace', () => { <input class="js-gl-managed" type="checkbox" value="1" checked /> <div class="js-namespace"> <input type="text" /> - </div> + </div> <div class="js-namespace-prefixed"> <input type="text" /> </div> diff --git a/spec/frontend/create_cluster/init_create_cluster_spec.js b/spec/frontend/create_cluster/init_create_cluster_spec.js new file mode 100644 index 00000000000..e7b9a7adde4 --- /dev/null +++ b/spec/frontend/create_cluster/init_create_cluster_spec.js @@ -0,0 +1,73 @@ +import initCreateCluster from '~/create_cluster/init_create_cluster'; +import initGkeDropdowns from '~/create_cluster/gke_cluster'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; +import PersistentUserCallout from '~/persistent_user_callout'; + +jest.mock('~/create_cluster/gke_cluster', () => jest.fn()); +jest.mock('~/create_cluster/gke_cluster_namespace', () => jest.fn()); +jest.mock('~/persistent_user_callout', () => ({ + factory: jest.fn(), +})); + +describe('initCreateCluster', () => { + let document; + let gon; + + beforeEach(() => { + document = { + body: { dataset: {} }, + querySelector: jest.fn(), + }; + gon = { features: {} }; + }); + afterEach(() => { + initGkeDropdowns.mockReset(); + initGkeNamespace.mockReset(); + PersistentUserCallout.factory.mockReset(); + }); + + describe.each` + pageSuffix | page + ${':clusters:new'} | ${'project:clusters:new'} + ${':clusters:create_gcp'} | ${'groups:clusters:create_gcp'} + ${':clusters:create_user'} | ${'admin:clusters:create_user'} + `('when cluster page ends in $pageSuffix', ({ page }) => { + beforeEach(() => { + document.body.dataset = { page }; + + initCreateCluster(document, gon); + }); + + it('initializes create GKE cluster app', () => { + expect(initGkeDropdowns).toHaveBeenCalled(); + }); + + it('initializes gcp signup offer banner', () => { + expect(PersistentUserCallout.factory).toHaveBeenCalled(); + }); + }); + + describe('when creating a project level cluster', () => { + it('initializes gke namespace app', () => { + document.body.dataset.page = 'project:clusters:new'; + + initCreateCluster(document, gon); + + expect(initGkeNamespace).toHaveBeenCalled(); + }); + }); + + describe.each` + clusterLevel | page + ${'group level'} | ${'groups:clusters:new'} + ${'instance level'} | ${'admin:clusters:create_gcp'} + `('when creating a $clusterLevel cluster', ({ page }) => { + it('does not initialize gke namespace app', () => { + document.body.dataset = { page }; + + initCreateCluster(document, gon); + + expect(initGkeNamespace).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index ff079082ca7..a7a1d563e1e 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -133,45 +133,19 @@ describe('StageNavItem', () => { hasStageName(); }); - it('renders options menu', () => { - expect(wrapper.find('.more-actions-toggle').exists()).toBe(true); + it('does not render options menu', () => { + expect(wrapper.find('.more-actions-toggle').exists()).toBe(false); }); - describe('Default stages', () => { - beforeEach(() => { - wrapper = createComponent( - { canEdit: true, isUserAllowed: true, isDefaultStage: true }, - false, - ); - }); - it('can hide the stage', () => { - expect(wrapper.text()).toContain('Hide stage'); - }); - it('can not edit the stage', () => { - expect(wrapper.text()).not.toContain('Edit stage'); - }); - it('can not remove the stage', () => { - expect(wrapper.text()).not.toContain('Remove stage'); - }); + it('can not edit the stage', () => { + expect(wrapper.text()).not.toContain('Edit stage'); + }); + it('can not remove the stage', () => { + expect(wrapper.text()).not.toContain('Remove stage'); }); - describe('Custom stages', () => { - beforeEach(() => { - wrapper = createComponent( - { canEdit: true, isUserAllowed: true, isDefaultStage: false }, - false, - ); - }); - it('can edit the stage', () => { - expect(wrapper.text()).toContain('Edit stage'); - }); - it('can remove the stage', () => { - expect(wrapper.text()).toContain('Remove stage'); - }); - - it('can not hide the stage', () => { - expect(wrapper.text()).not.toContain('Hide stage'); - }); + it('can not hide the stage', () => { + expect(wrapper.text()).not.toContain('Hide stage'); }); }); }); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 290c0e797cb..3c6553f3547 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -41,6 +41,12 @@ class CustomEnvironment extends JSDOMEnvironment { this.global.fixturesBasePath = `${ROOT_PATH}/tmp/tests/frontend/fixtures${IS_EE ? '-ee' : ''}`; this.global.staticFixturesBasePath = `${ROOT_PATH}/spec/frontend/fixtures`; + /** + * window.fetch() is required by the apollo-upload-client library otherwise + * a ReferenceError is generated: https://github.com/jaydenseric/apollo-upload-client/issues/100 + */ + this.global.fetch = () => {}; + // Not yet supported by JSDOM: https://github.com/jsdom/jsdom/issues/317 this.global.document.createRange = () => ({ setStart: () => {}, diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js new file mode 100644 index 00000000000..54e8b0848a2 --- /dev/null +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -0,0 +1,105 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import ErrorDetails from '~/error_tracking/components/error_details.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ErrorDetails', () => { + let store; + let wrapper; + let actions; + let getters; + + function mountComponent() { + wrapper = shallowMount(ErrorDetails, { + localVue, + store, + propsData: { + issueDetailsPath: '/123/details', + issueStackTracePath: '/stacktrace', + }, + }); + } + + beforeEach(() => { + actions = { + startPollingDetails: () => {}, + startPollingStacktrace: () => {}, + }; + + getters = { + sentryUrl: () => 'sentry.io', + stacktrace: () => [{ context: [1, 2], lineNo: 53, filename: 'index.js' }], + }; + + const state = { + error: {}, + loading: true, + stacktraceData: {}, + loadingStacktrace: true, + }; + + store = new Vuex.Store({ + modules: { + details: { + namespaced: true, + actions, + state, + getters, + }, + }, + }); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + describe('loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('should show spinner while loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(GlLink).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + }); + + describe('Error details', () => { + it('should show Sentry error details without stacktrace', () => { + store.state.details.loading = false; + store.state.details.error.id = 1; + mountComponent(); + expect(wrapper.find(GlLink).exists()).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + + describe('Stacktrace', () => { + it('should show stacktrace', () => { + store.state.details.loading = false; + store.state.details.error.id = 1; + store.state.details.loadingStacktrace = false; + mountComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(true); + }); + + it('should NOT show stacktrace if no entries', () => { + store.state.details.loading = false; + store.state.details.loadingStacktrace = false; + store.getters = { 'details/sentryUrl': () => 'sentry.io', 'details/stacktrace': () => [] }; + mountComponent(); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(Stacktrace).exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index ce8b8908026..1bbf23cc602 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -34,7 +34,7 @@ describe('ErrorTrackingList', () => { beforeEach(() => { actions = { - getErrorList: () => {}, + getSentryData: () => {}, startPolling: () => {}, restartPolling: jest.fn().mockName('restartPolling'), }; @@ -45,8 +45,13 @@ describe('ErrorTrackingList', () => { }; store = new Vuex.Store({ - actions, - state, + modules: { + list: { + namespaced: true, + actions, + state, + }, + }, }); }); @@ -70,7 +75,7 @@ describe('ErrorTrackingList', () => { describe('results', () => { beforeEach(() => { - store.state.loading = false; + store.state.list.loading = false; mountComponent(); }); @@ -84,7 +89,7 @@ describe('ErrorTrackingList', () => { describe('no results', () => { beforeEach(() => { - store.state.loading = false; + store.state.list.loading = false; mountComponent(); }); diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js new file mode 100644 index 00000000000..95958408770 --- /dev/null +++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +describe('Stacktrace Entry', () => { + let wrapper; + + function mountComponent(props) { + wrapper = shallowMount(StackTraceEntry, { + propsData: { + filePath: 'sidekiq/util.rb', + lines: [ + [22, ' def safe_thread(name, \u0026block)\n'], + [23, ' Thread.new do\n'], + [24, " Thread.current['sidekiq_label'] = name\n"], + [25, ' watchdog(name, \u0026block)\n'], + ], + errorLine: 24, + ...props, + }, + }); + } + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('should render stacktrace entry collapsed', () => { + expect(wrapper.find(StackTraceEntry).exists()).toBe(true); + expect(wrapper.find(ClipboardButton).exists()).toBe(true); + expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(FileIcon).exists()).toBe(true); + expect(wrapper.element.querySelectorAll('table').length).toBe(0); + }); + + it('should render stacktrace entry table expanded', () => { + mountComponent({ expanded: true }); + expect(wrapper.element.querySelectorAll('tr.line_holder').length).toBe(4); + expect(wrapper.element.querySelectorAll('.line_content.old').length).toBe(1); + }); +}); diff --git a/spec/frontend/error_tracking/components/stacktrace_spec.js b/spec/frontend/error_tracking/components/stacktrace_spec.js new file mode 100644 index 00000000000..4f4a60acba4 --- /dev/null +++ b/spec/frontend/error_tracking/components/stacktrace_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; +import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue'; + +describe('ErrorDetails', () => { + let wrapper; + + const stackTraceEntry = { + filename: 'sidekiq/util.rb', + context: [ + [22, ' def safe_thread(name, \u0026block)\n'], + [23, ' Thread.new do\n'], + [24, " Thread.current['sidekiq_label'] = name\n"], + [25, ' watchdog(name, \u0026block)\n'], + ], + lineNo: 24, + }; + + function mountComponent(entries) { + wrapper = shallowMount(Stacktrace, { + propsData: { + entries, + }, + }); + } + + describe('Stacktrace', () => { + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('should render single Stacktrace entry', () => { + mountComponent([stackTraceEntry]); + expect(wrapper.findAll(StackTraceEntry).length).toBe(1); + }); + + it('should render multiple Stacktrace entry', () => { + const entriesNum = 3; + mountComponent(new Array(entriesNum).fill(stackTraceEntry)); + expect(wrapper.findAll(StackTraceEntry).length).toBe(entriesNum); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js new file mode 100644 index 00000000000..f72cd1e413b --- /dev/null +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -0,0 +1,94 @@ +import axios from '~/lib/utils/axios_utils'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import * as actions from '~/error_tracking/store/details/actions'; +import * as types from '~/error_tracking/store/details/mutation_types'; + +jest.mock('~/flash.js'); +let mock; + +describe('Sentry error details store actions', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + createFlash.mockClear(); + }); + + describe('startPollingDetails', () => { + const endpoint = '123/details'; + it('should commit SET_ERROR with received response', done => { + const payload = { error: { id: 1 } }; + mock.onGet().reply(200, payload); + testAction( + actions.startPollingDetails, + { endpoint }, + {}, + [ + { type: types.SET_ERROR, payload: payload.error }, + { type: types.SET_LOADING, payload: false }, + ], + [], + () => { + done(); + }, + ); + }); + + it('should show flash on API error', done => { + mock.onGet().reply(400); + + testAction( + actions.startPollingDetails, + { endpoint }, + {}, + [{ type: types.SET_LOADING, payload: false }], + [], + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + done(); + }, + ); + }); + }); + + describe('startPollingStacktrace', () => { + const endpoint = '123/stacktrace'; + it('should commit SET_ERROR with received response', done => { + const payload = { error: [1, 2, 3] }; + mock.onGet().reply(200, payload); + testAction( + actions.startPollingStacktrace, + { endpoint }, + {}, + [ + { type: types.SET_STACKTRACE_DATA, payload: payload.error }, + { type: types.SET_LOADING_STACKTRACE, payload: false }, + ], + [], + () => { + done(); + }, + ); + }); + + it('should show flash on API error', done => { + mock.onGet().reply(400); + + testAction( + actions.startPollingStacktrace, + { endpoint }, + {}, + [{ type: types.SET_LOADING_STACKTRACE, payload: false }], + [], + () => { + expect(createFlash).toHaveBeenCalledTimes(1); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/details/getters_spec.js b/spec/frontend/error_tracking/store/details/getters_spec.js new file mode 100644 index 00000000000..ea57de5872b --- /dev/null +++ b/spec/frontend/error_tracking/store/details/getters_spec.js @@ -0,0 +1,13 @@ +import * as getters from '~/error_tracking/store/details/getters'; + +describe('Sentry error details store getters', () => { + const state = { + stacktraceData: { stack_trace_entries: [1, 2] }, + }; + + describe('stacktrace', () => { + it('should get stacktrace', () => { + expect(getters.stacktrace(state)).toEqual([2, 1]); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/list/getters_spec.js b/spec/frontend/error_tracking/store/list/getters_spec.js new file mode 100644 index 00000000000..3cd7fa37d44 --- /dev/null +++ b/spec/frontend/error_tracking/store/list/getters_spec.js @@ -0,0 +1,33 @@ +import * as getters from '~/error_tracking/store/list/getters'; + +describe('Error Tracking getters', () => { + let state; + + const mockErrors = [ + { title: 'ActiveModel::MissingAttributeError: missing attribute: encrypted_password' }, + { title: 'Grape::Exceptions::MethodNotAllowed: Grape::Exceptions::MethodNotAllowed' }, + { title: 'NoMethodError: undefined method `sanitize_http_headers=' }, + { title: 'NoMethodError: undefined method `pry' }, + ]; + + beforeEach(() => { + state = { + errors: mockErrors, + }; + }); + + describe('search results', () => { + it('should return errors filtered by words in title matching the query', () => { + const filteredErrors = getters.filterErrorsByTitle(state)('NoMethod'); + + expect(filteredErrors).not.toContainEqual(mockErrors[0]); + expect(filteredErrors.length).toBe(2); + }); + + it('should not return results if there is no matching query', () => { + const filteredErrors = getters.filterErrorsByTitle(state)('GitLab'); + + expect(filteredErrors.length).toBe(0); + }); + }); +}); diff --git a/spec/frontend/error_tracking/store/mutation_spec.js b/spec/frontend/error_tracking/store/list/mutation_spec.js index 8117104bdbc..6e021185b4d 100644 --- a/spec/frontend/error_tracking/store/mutation_spec.js +++ b/spec/frontend/error_tracking/store/list/mutation_spec.js @@ -1,5 +1,5 @@ -import mutations from '~/error_tracking/store/mutations'; -import * as types from '~/error_tracking/store/mutation_types'; +import mutations from '~/error_tracking/store/list/mutations'; +import * as types from '~/error_tracking/store/list/mutation_types'; describe('Error tracking mutations', () => { describe('SET_ERRORS', () => { diff --git a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js index 23e57c4bbf1..bff8ad0877a 100644 --- a/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js +++ b/spec/frontend/error_tracking_settings/components/error_tracking_form_spec.js @@ -1,7 +1,9 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlButton, GlFormInput } from '@gitlab/ui'; +import { GlFormInput } from '@gitlab/ui'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; import ErrorTrackingForm from '~/error_tracking_settings/components/error_tracking_form.vue'; +import createStore from '~/error_tracking_settings/store'; import { defaultProps } from '../mock'; const localVue = createLocalVue(); @@ -9,15 +11,18 @@ localVue.use(Vuex); describe('error tracking settings form', () => { let wrapper; + let store; function mountComponent() { wrapper = shallowMount(ErrorTrackingForm, { localVue, + store, propsData: defaultProps, }); } beforeEach(() => { + store = createStore(); mountComponent(); }); @@ -38,7 +43,7 @@ describe('error tracking settings form', () => { .attributes('id'), ).toBe('error-tracking-token'); - expect(wrapper.findAll(GlButton).exists()).toBe(true); + expect(wrapper.findAll(LoadingButton).exists()).toBe(true); }); it('is rendered with labels and placeholders', () => { @@ -59,9 +64,21 @@ describe('error tracking settings form', () => { }); }); + describe('loading projects', () => { + beforeEach(() => { + store.state.isLoadingProjects = true; + }); + + it('shows loading spinner', () => { + const { label, loading } = wrapper.find(LoadingButton).props(); + expect(loading).toBe(true); + expect(label).toBe('Connecting'); + }); + }); + describe('after a successful connection', () => { beforeEach(() => { - wrapper.setProps({ connectSuccessful: true }); + store.state.connectSuccessful = true; }); it('shows the success checkmark', () => { @@ -77,7 +94,7 @@ describe('error tracking settings form', () => { describe('after an unsuccessful connection', () => { beforeEach(() => { - wrapper.setProps({ connectError: true }); + store.state.connectError = true; }); it('does not show the check mark', () => { diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 1eab0f7470b..e12c4e20f58 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -69,7 +69,14 @@ describe('error tracking settings actions', () => { }); it('should request projects correctly', done => { - testAction(actions.requestProjects, null, state, [{ type: types.RESET_CONNECT }], [], done); + testAction( + actions.requestProjects, + null, + state, + [{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }], + [], + done, + ); }); it('should receive projects correctly', done => { @@ -81,6 +88,7 @@ describe('error tracking settings actions', () => { [ { type: types.UPDATE_CONNECT_SUCCESS }, { type: types.RECEIVE_PROJECTS, payload: testPayload }, + { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], done, @@ -93,7 +101,11 @@ describe('error tracking settings actions', () => { actions.receiveProjectsError, testPayload, state, - [{ type: types.UPDATE_CONNECT_ERROR }, { type: types.CLEAR_PROJECTS }], + [ + { type: types.UPDATE_CONNECT_ERROR }, + { type: types.CLEAR_PROJECTS }, + { type: types.SET_PROJECTS_LOADING, payload: false }, + ], [], done, ); diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index 8fbdb534b3d..f20c0aa3540 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -8,7 +8,23 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project, :repository, namespace: namespace, path: 'merge-requests-project') } - let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') } + + # rubocop: disable Layout/TrailingWhitespace + let(:merge_request) do + create( + :merge_request, + :with_diffs, + source_project: project, + target_project: project, + description: <<~MARKDOWN.strip_heredoc + - [ ] Task List Item + - [ ] + - [ ] Task List Item 2 + MARKDOWN + ) + end + # rubocop: enable Layout/TrailingWhitespace + let(:merged_merge_request) { create(:merge_request, :merged, source_project: project, target_project: project) } let(:pipeline) do create( diff --git a/spec/frontend/fixtures/static/environments_logs.html b/spec/frontend/fixtures/static/environments_logs.html index ccf9c364154..88bb0a3ed41 100644 --- a/spec/frontend/fixtures/static/environments_logs.html +++ b/spec/frontend/fixtures/static/environments_logs.html @@ -2,8 +2,8 @@ class="js-kubernetes-logs" data-current-environment-name="production" data-environments-path="/root/my-project/environments.json" - data-logs-page="/root/my-project/environments/1/logs" - data-logs-path="/root/my-project/environments/1/logs.json" + data-project-full-path="root/my-project" + data-environment-id=1 > <div class="build-page-pod-logs"> <div class="build-trace-container prepend-top-default"> diff --git a/spec/frontend/fixtures/static/signin_tabs.html b/spec/frontend/fixtures/static/signin_tabs.html index 7e66ab9394b..247a6b03054 100644 --- a/spec/frontend/fixtures/static/signin_tabs.html +++ b/spec/frontend/fixtures/static/signin_tabs.html @@ -5,4 +5,7 @@ <li> <a href="#login-pane">Standard</a> </li> +<li> +<a href="#register-pane">Register</a> +</li> </ul> diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb index dded6ce6380..9710fbbc181 100644 --- a/spec/frontend/fixtures/u2f.rb +++ b/spec/frontend/fixtures/u2f.rb @@ -34,7 +34,9 @@ context 'U2F' do before do sign_in(user) - allow_any_instance_of(Profiles::TwoFactorAuthsController).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares') + allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance| + allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares') + end end it 'u2f/register.html' do diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap new file mode 100644 index 00000000000..69ad71a1efb --- /dev/null +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`grafana integration component default state to match the default snapshot 1`] = ` +<section + class="settings no-animate js-grafana-integration" + id="grafana" +> + <div + class="settings-header" + > + <h4 + class="js-section-header" + > + + Grafana Authentication + + </h4> + + <glbutton-stub + class="js-settings-toggle" + > + Expand + </glbutton-stub> + + <p + class="js-section-sub-header" + > + + Embed Grafana charts in GitLab issues. + + </p> + </div> + + <div + class="settings-content" + > + <form> + <glformcheckbox-stub + class="mb-4" + id="grafana-integration-enabled" + > + + Active + + </glformcheckbox-stub> + + <glformgroup-stub + description="Enter the base URL of the Grafana instance." + label="Grafana URL" + label-for="grafana-url" + > + <glforminput-stub + id="grafana-url" + placeholder="https://my-url.grafana.net/" + value="http://test.host" + /> + </glformgroup-stub> + + <glformgroup-stub + label="API Token" + label-for="grafana-token" + > + <glforminput-stub + id="grafana-token" + value="someToken" + /> + + <p + class="form-text text-muted" + > + + Enter the Grafana API Token. + + <a + href="https://grafana.com/docs/http_api/auth/#create-api-token" + rel="noopener noreferrer" + target="_blank" + > + + More information + + <icon-stub + class="vertical-align-middle" + name="external-link" + size="16" + /> + </a> + </p> + </glformgroup-stub> + + <glbutton-stub + variant="success" + > + + Save Changes + + </glbutton-stub> + </form> + </div> +</section> +`; diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js new file mode 100644 index 00000000000..c098ada0519 --- /dev/null +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -0,0 +1,125 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue'; +import { createStore } from '~/grafana_integration/store'; +import axios from '~/lib/utils/axios_utils'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import { TEST_HOST } from 'helpers/test_constants'; + +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); + +describe('grafana integration component', () => { + let wrapper; + let store; + const operationsSettingsEndpoint = `${TEST_HOST}/mock/ops/settings/endpoint`; + const grafanaIntegrationUrl = `${TEST_HOST}`; + const grafanaIntegrationToken = 'someToken'; + + beforeEach(() => { + store = createStore({ + operationsSettingsEndpoint, + grafanaIntegrationUrl, + grafanaIntegrationToken, + }); + }); + + afterEach(() => { + if (wrapper.destroy) { + wrapper.destroy(); + createFlash.mockReset(); + refreshCurrentPage.mockReset(); + } + }); + + describe('default state', () => { + it('to match the default snapshot', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + it('renders header text', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + expect(wrapper.find('.js-section-header').text()).toBe('Grafana Authentication'); + }); + + describe('expand/collapse button', () => { + it('renders as an expand button by default', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + const button = wrapper.find(GlButton); + + expect(button.text()).toBe('Expand'); + }); + }); + + describe('sub-header', () => { + it('renders descriptive text', () => { + wrapper = shallowMount(GrafanaIntegration, { store }); + + expect(wrapper.find('.js-section-sub-header').text()).toContain( + 'Embed Grafana charts in GitLab issues.', + ); + }); + }); + + describe('form', () => { + beforeEach(() => { + jest.spyOn(axios, 'patch').mockImplementation(); + }); + + afterEach(() => { + axios.patch.mockReset(); + }); + + describe('submit button', () => { + const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton); + + const endpointRequest = [ + operationsSettingsEndpoint, + { + project: { + grafana_integration_attributes: { + grafana_url: grafanaIntegrationUrl, + token: grafanaIntegrationToken, + enabled: false, + }, + }, + }, + ]; + + it('submits form on click', () => { + wrapper = mount(GrafanaIntegration, { store }); + axios.patch.mockResolvedValue(); + + findSubmitButton(wrapper).trigger('click'); + + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); + return wrapper.vm.$nextTick().then(() => expect(refreshCurrentPage).toHaveBeenCalled()); + }); + + it('creates flash banner on error', () => { + const message = 'mockErrorMessage'; + wrapper = mount(GrafanaIntegration, { store }); + axios.patch.mockRejectedValue({ response: { data: { message } } }); + + findSubmitButton().trigger('click'); + + expect(axios.patch).toHaveBeenCalledWith(...endpointRequest); + return wrapper.vm + .$nextTick() + .then(jest.runAllTicks) + .then(() => + expect(createFlash).toHaveBeenCalledWith( + `There was an error saving your changes. ${message}`, + 'alert', + ), + ); + }); + }); + }); +}); diff --git a/spec/frontend/grafana_integration/store/mutations_spec.js b/spec/frontend/grafana_integration/store/mutations_spec.js new file mode 100644 index 00000000000..18e87394189 --- /dev/null +++ b/spec/frontend/grafana_integration/store/mutations_spec.js @@ -0,0 +1,35 @@ +import mutations from '~/grafana_integration/store/mutations'; +import createState from '~/grafana_integration/store/state'; + +describe('grafana integration mutations', () => { + let localState; + + beforeEach(() => { + localState = createState(); + }); + + describe('SET_GRAFANA_URL', () => { + it('sets grafanaUrl', () => { + const mockUrl = 'mockUrl'; + mutations.SET_GRAFANA_URL(localState, mockUrl); + + expect(localState.grafanaUrl).toBe(mockUrl); + }); + }); + + describe('SET_GRAFANA_TOKEN', () => { + it('sets grafanaToken', () => { + const mockToken = 'mockToken'; + mutations.SET_GRAFANA_TOKEN(localState, mockToken); + + expect(localState.grafanaToken).toBe(mockToken); + }); + }); + describe('SET_GRAFANA_ENABLED', () => { + it('updates grafanaEnabled for integration', () => { + mutations.SET_GRAFANA_ENABLED(localState, true); + + expect(localState.grafanaEnabled).toBe(true); + }); + }); +}); diff --git a/spec/frontend/helpers/monitor_helper_spec.js b/spec/frontend/helpers/monitor_helper_spec.js index 2e8bff298c4..0798ca580e2 100644 --- a/spec/frontend/helpers/monitor_helper_spec.js +++ b/spec/frontend/helpers/monitor_helper_spec.js @@ -41,5 +41,87 @@ describe('monitor helper', () => { ), ).toEqual([{ ...expectedDataSeries[0], data: [[1, 1]] }]); }); + + it('updates series name from templates', () => { + const config = { + ...defaultConfig, + name: '{{cmd}}', + }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { cmd: 'brpop' }, values: series }], + config, + ); + + expect(result.name).toEqual('brpop'); + }); + + it('supports space-padded template expressions', () => { + const config = { + ...defaultConfig, + name: 'backend: {{ backend }}', + }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { backend: 'HA Server' }, values: series }], + config, + ); + + expect(result.name).toEqual('backend: HA Server'); + }); + + it('supports repeated template variables', () => { + const config = { ...defaultConfig, name: '{{cmd}}, {{cmd}}' }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { cmd: 'brpop' }, values: series }], + config, + ); + + expect(result.name).toEqual('brpop, brpop'); + }); + + it('supports hyphenated template variables', () => { + const config = { ...defaultConfig, name: 'expired - {{ test-attribute }}' }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { 'test-attribute': 'test-attribute-value' }, values: series }], + config, + ); + + expect(result.name).toEqual('expired - test-attribute-value'); + }); + + it('updates multiple series names from templates', () => { + const config = { + ...defaultConfig, + name: '{{job}}: {{cmd}}', + }; + + const [result] = monitorHelper.makeDataSeries( + [{ metric: { cmd: 'brpop', job: 'redis' }, values: series }], + config, + ); + + expect(result.name).toEqual('redis: brpop'); + }); + + it('updates name for each series', () => { + const config = { + ...defaultConfig, + name: '{{cmd}}', + }; + + const [firstSeries, secondSeries] = monitorHelper.makeDataSeries( + [ + { metric: { cmd: 'brpop' }, values: series }, + { metric: { cmd: 'zrangebyscore' }, values: series }, + ], + config, + ); + + expect(firstSeries.name).toEqual('brpop'); + expect(secondSeries.name).toEqual('zrangebyscore'); + }); }); }); diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap new file mode 100644 index 00000000000..5d6c31f01d9 --- /dev/null +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IDE pipeline stage renders stage details & icon 1`] = ` +<div + class="ide-stage card prepend-top-default" +> + <div + class="card-header" + > + <ciicon-stub + cssclasses="" + size="24" + status="[object Object]" + /> + + <strong + class="prepend-left-8 ide-stage-title" + data-container="body" + data-original-title="" + title="" + > + + build + + </strong> + + <div + class="append-right-8 prepend-left-4" + > + <span + class="badge badge-pill" + > + 4 + </span> + </div> + + <icon-stub + class="ide-stage-collapse-icon" + name="angle-down" + size="16" + /> + </div> + + <div + class="card-body" + > + <item-stub + job="[object Object]" + /> + <item-stub + job="[object Object]" + /> + <item-stub + job="[object Object]" + /> + <item-stub + job="[object Object]" + /> + </div> +</div> +`; diff --git a/spec/frontend/ide/components/jobs/stage_spec.js b/spec/frontend/ide/components/jobs/stage_spec.js new file mode 100644 index 00000000000..2e42ab26d27 --- /dev/null +++ b/spec/frontend/ide/components/jobs/stage_spec.js @@ -0,0 +1,86 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Stage from '~/ide/components/jobs/stage.vue'; +import Item from '~/ide/components/jobs/item.vue'; +import { stages, jobs } from '../../mock_data'; + +describe('IDE pipeline stage', () => { + let wrapper; + const defaultProps = { + stage: { + ...stages[0], + id: 0, + dropdownPath: stages[0].dropdown_path, + jobs: [...jobs], + isLoading: false, + isCollapsed: false, + }, + }; + + const findHeader = () => wrapper.find({ ref: 'cardHeader' }); + const findJobList = () => wrapper.find({ ref: 'jobList' }); + + const createComponent = props => { + wrapper = shallowMount(Stage, { + propsData: { + ...defaultProps, + ...props, + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('emits fetch event when mounted', () => { + createComponent(); + expect(wrapper.emitted().fetch).toBeDefined(); + }); + + it('renders loading icon when no jobs and isLoading is true', () => { + createComponent({ + stage: { ...defaultProps.stage, isLoading: true, jobs: [] }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('emits toggleCollaped event with stage id when clicking header', () => { + const id = 5; + createComponent({ stage: { ...defaultProps.stage, id } }); + findHeader().trigger('click'); + expect(wrapper.emitted().toggleCollapsed[0][0]).toBe(id); + }); + + it('emits clickViewLog entity with job', () => { + const [job] = defaultProps.stage.jobs; + createComponent(); + wrapper + .findAll(Item) + .at(0) + .vm.$emit('clickViewLog', job); + expect(wrapper.emitted().clickViewLog[0][0]).toBe(job); + }); + + it('renders stage details & icon', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('when collapsed', () => { + beforeEach(() => { + createComponent({ stage: { ...defaultProps.stage, isCollapsed: true } }); + }); + + it('does not render job list', () => { + expect(findJobList().isVisible()).toBe(false); + }); + + it('sets border bottom class', () => { + expect(findHeader().classes('border-bottom-0')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index dfc76628d0c..6a33f4998c5 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -24,6 +24,9 @@ describe('IDE clientside preview', () => { getFileData: jest.fn().mockReturnValue(Promise.resolve({})), getRawFileData: jest.fn().mockReturnValue(Promise.resolve('')), }; + const storeClientsideActions = { + pingUsage: jest.fn().mockReturnValue(Promise.resolve({})), + }; const waitForCalls = () => new Promise(setImmediate); @@ -42,6 +45,12 @@ describe('IDE clientside preview', () => { ...getters, }, actions: storeActions, + modules: { + clientside: { + namespaced: true, + actions: storeClientsideActions, + }, + }, }); wrapper = shallowMount(Clientside, { @@ -76,7 +85,8 @@ describe('IDE clientside preview', () => { describe('with main entry', () => { beforeEach(() => { createComponent({ getters: { packageJson: dummyPackageJson } }); - return wrapper.vm.initPreview(); + + return waitForCalls(); }); it('creates sandpack manager', () => { @@ -95,6 +105,10 @@ describe('IDE clientside preview', () => { }, ); }); + + it('pings usage', () => { + expect(storeClientsideActions.pingUsage).toHaveBeenCalledTimes(1); + }); }); describe('computed', () => { @@ -178,13 +192,13 @@ describe('IDE clientside preview', () => { }); describe('showOpenInCodeSandbox', () => { - it('returns true when visiblity is public', () => { + it('returns true when visibility is public', () => { createComponent({ getters: { currentProject: () => ({ visibility: 'public' }) } }); expect(wrapper.vm.showOpenInCodeSandbox).toBe(true); }); - it('returns false when visiblity is private', () => { + it('returns false when visibility is private', () => { createComponent({ getters: { currentProject: () => ({ visibility: 'private' }) } }); expect(wrapper.vm.showOpenInCodeSandbox).toBe(false); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 3d5ed4b5c0c..bb0d20bed91 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -1,11 +1,18 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; import services from '~/ide/services'; import Api from '~/api'; +import { escapeFileUrl } from '~/ide/stores/utils'; jest.mock('~/api'); const TEST_PROJECT_ID = 'alice/wonderland'; const TEST_BRANCH = 'master-patch-123'; const TEST_COMMIT_SHA = '123456789'; +const TEST_FILE_PATH = 'README2.md'; +const TEST_FILE_OLD_PATH = 'OLD_README2.md'; +const TEST_FILE_PATH_SPECIAL = 'READM?ME/abc'; +const TEST_FILE_CONTENTS = 'raw file content'; describe('IDE services', () => { describe('commit', () => { @@ -28,4 +35,80 @@ describe('IDE services', () => { expect(Api.commitMultiple).toHaveBeenCalledWith(TEST_PROJECT_ID, payload); }); }); + + describe('getBaseRawFileData', () => { + let file; + let mock; + + beforeEach(() => { + file = { + mrChange: null, + projectId: TEST_PROJECT_ID, + path: TEST_FILE_PATH, + }; + + jest.spyOn(axios, 'get'); + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('gives back file.baseRaw for files with that property present', () => { + file.baseRaw = TEST_FILE_CONTENTS; + + return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + expect(content).toEqual(TEST_FILE_CONTENTS); + }); + }); + + it('gives back file.baseRaw for files for temp files', () => { + file.tempFile = true; + file.baseRaw = TEST_FILE_CONTENTS; + + return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + expect(content).toEqual(TEST_FILE_CONTENTS); + }); + }); + + describe.each` + relativeUrlRoot | filePath | isRenamed + ${''} | ${TEST_FILE_PATH} | ${false} + ${''} | ${TEST_FILE_OLD_PATH} | ${true} + ${''} | ${TEST_FILE_PATH_SPECIAL} | ${false} + ${''} | ${TEST_FILE_PATH_SPECIAL} | ${true} + ${'gitlab'} | ${TEST_FILE_OLD_PATH} | ${true} + `( + 'with relativeUrlRoot ($relativeUrlRoot) and filePath ($filePath) and isRenamed ($isRenamed)', + ({ relativeUrlRoot, filePath, isRenamed }) => { + beforeEach(() => { + if (isRenamed) { + file.mrChange = { + renamed_file: true, + old_path: filePath, + }; + } else { + file.path = filePath; + } + + gon.relative_url_root = relativeUrlRoot; + + mock + .onGet( + `${relativeUrlRoot}/${TEST_PROJECT_ID}/raw/${TEST_COMMIT_SHA}/${escapeFileUrl( + filePath, + )}`, + ) + .reply(200, TEST_FILE_CONTENTS); + }); + + it('fetches file content', () => + services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + expect(content).toEqual(TEST_FILE_CONTENTS); + })); + }, + ); + }); }); diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js new file mode 100644 index 00000000000..a47bc0bd711 --- /dev/null +++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js @@ -0,0 +1,39 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/ide/stores/modules/clientside/actions'; + +const TEST_PROJECT_URL = `${TEST_HOST}/lorem/ipsum`; +const TEST_USAGE_URL = `${TEST_PROJECT_URL}/usage_ping/web_ide_clientside_preview`; + +describe('IDE store module clientside actions', () => { + let rootGetters; + let mock; + + beforeEach(() => { + rootGetters = { + currentProject: { + web_url: TEST_PROJECT_URL, + }, + }; + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('pingUsage', () => { + it('posts to usage endpoint', done => { + const usageSpy = jest.fn(() => [200]); + + mock.onPost(TEST_USAGE_URL).reply(() => usageSpy()); + + testAction(actions.pingUsage, null, rootGetters, [], [], () => { + expect(usageSpy).toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap new file mode 100644 index 00000000000..f57391a6b0d --- /dev/null +++ b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = ` +<glemptystate-stub + description="The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project." + svgpath="/emptySvg" + title="There are no issues to show" +/> +`; + +exports[`Issuables list component with empty issues response with closed state should display a message "There are no closed issues" if there are no closed issues 1`] = `"There are no closed issues"`; + +exports[`Issuables list component with empty issues response with empty query should display the message "There are no open issues" 1`] = `"There are no open issues"`; + +exports[`Issuables list component with empty issues response with query in window location should display "Sorry, your filter produced no results" if filters are too specific 1`] = `"Sorry, your filter produced no results"`; diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js new file mode 100644 index 00000000000..6148f3c68f2 --- /dev/null +++ b/spec/frontend/issuables_list/components/issuable_spec.js @@ -0,0 +1,345 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import { trimText } from 'helpers/text_helper'; +import initUserPopovers from '~/user_popovers'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import Issuable from '~/issuables_list/components/issuable.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; +import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; + +jest.mock('~/user_popovers'); + +const TEST_NOW = '2019-08-28T20:03:04.713Z'; +const TEST_MONTH_AGO = '2019-07-28'; +const TEST_MONTH_LATER = '2019-09-30'; +const DATE_FORMAT = 'mmm d, yyyy'; +const TEST_USER_NAME = 'Tyler Durden'; +const TEST_BASE_URL = `${TEST_HOST}/issues`; +const TEST_TASK_STATUS = '50 of 100 tasks completed'; +const TEST_MILESTONE = { + title: 'Milestone title', + web_url: `${TEST_HOST}/milestone/1`, +}; +const TEXT_CLOSED = 'CLOSED'; +const TEST_META_COUNT = 100; + +// Use FixedDate so that time sensitive info in snapshots don't fail +class FixedDate extends Date { + constructor(date = TEST_NOW) { + super(date); + } +} + +describe('Issuable component', () => { + let issuable; + let DateOrig; + let wrapper; + + const factory = (props = {}) => { + wrapper = shallowMount(Issuable, { + propsData: { + issuable: simpleIssue, + baseUrl: TEST_BASE_URL, + ...props, + }, + sync: false, + }); + }; + + beforeEach(() => { + issuable = { ...simpleIssue }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + beforeAll(() => { + DateOrig = window.Date; + window.Date = FixedDate; + }); + + afterAll(() => { + window.Date = DateOrig; + }); + + const findConfidentialIcon = () => wrapper.find('.fa-eye-slash'); + const findTaskStatus = () => wrapper.find('.task-status'); + const findOpenedAgoContainer = () => wrapper.find({ ref: 'openedAgoByContainer' }); + const findMilestone = () => wrapper.find('.js-milestone'); + const findMilestoneTooltip = () => findMilestone().attributes('data-original-title'); + const findDueDate = () => wrapper.find('.js-due-date'); + const findLabelContainer = () => wrapper.find('.js-labels'); + const findLabelLinks = () => findLabelContainer().findAll(GlLink); + const findWeight = () => wrapper.find('.js-weight'); + const findAssignees = () => wrapper.find(IssueAssignees); + const findMergeRequestsCount = () => wrapper.find('.js-merge-requests'); + const findUpvotes = () => wrapper.find('.js-upvotes'); + const findDownvotes = () => wrapper.find('.js-downvotes'); + const findNotes = () => wrapper.find('.js-notes'); + const findBulkCheckbox = () => wrapper.find('input.selected-issuable'); + + describe('when mounted', () => { + it('initializes user popovers', () => { + expect(initUserPopovers).not.toHaveBeenCalled(); + + factory(); + + expect(initUserPopovers).toHaveBeenCalledWith([findOpenedAgoContainer().find('a').element]); + }); + }); + + describe('with simple issuable', () => { + beforeEach(() => { + Object.assign(issuable, { + has_tasks: false, + task_status: TEST_TASK_STATUS, + created_at: TEST_MONTH_AGO, + author: { + ...issuable.author, + name: TEST_USER_NAME, + }, + labels: [], + }); + + factory({ issuable }); + }); + + it.each` + desc | finder + ${'bulk editing checkbox'} | ${findBulkCheckbox} + ${'confidential icon'} | ${findConfidentialIcon} + ${'task status'} | ${findTaskStatus} + ${'milestone'} | ${findMilestone} + ${'due date'} | ${findDueDate} + ${'labels'} | ${findLabelContainer} + ${'weight'} | ${findWeight} + ${'merge request count'} | ${findMergeRequestsCount} + ${'upvotes'} | ${findUpvotes} + ${'downvotes'} | ${findDownvotes} + `('does not render $desc', ({ finder }) => { + expect(finder().exists()).toBe(false); + }); + + it('does not have closed text', () => { + expect(wrapper.text()).not.toContain(TEXT_CLOSED); + }); + + it('does not have closed class', () => { + expect(wrapper.classes('closed')).toBe(false); + }); + + it('renders fuzzy opened date and author', () => { + expect(trimText(findOpenedAgoContainer().text())).toEqual( + `opened 1 month ago by ${TEST_USER_NAME}`, + ); + }); + + it('renders no comments', () => { + expect(findNotes().classes('no-comments')).toBe(true); + }); + }); + + describe('with confidential issuable', () => { + beforeEach(() => { + issuable.confidential = true; + + factory({ issuable }); + }); + + it('renders the confidential icon', () => { + expect(findConfidentialIcon().exists()).toBe(true); + }); + }); + + describe('with task status', () => { + beforeEach(() => { + Object.assign(issuable, { + has_tasks: true, + task_status: TEST_TASK_STATUS, + }); + + factory({ issuable }); + }); + + it('renders task status', () => { + expect(findTaskStatus().exists()).toBe(true); + expect(findTaskStatus().text()).toBe(TEST_TASK_STATUS); + }); + }); + + describe.each` + desc | dueDate | expectedTooltipPart + ${'past due'} | ${TEST_MONTH_AGO} | ${'Past due'} + ${'future due'} | ${TEST_MONTH_LATER} | ${'1 month remaining'} + `('with milestone with $desc', ({ dueDate, expectedTooltipPart }) => { + beforeEach(() => { + issuable.milestone = { ...TEST_MILESTONE, due_date: dueDate }; + + factory({ issuable }); + }); + + it('renders milestone', () => { + expect(findMilestone().exists()).toBe(true); + expect( + findMilestone() + .find('.fa-clock-o') + .exists(), + ).toBe(true); + expect(findMilestone().text()).toEqual(TEST_MILESTONE.title); + }); + + it('renders tooltip', () => { + expect(findMilestoneTooltip()).toBe( + `${formatDate(dueDate, DATE_FORMAT)} (${expectedTooltipPart})`, + ); + }); + + it('renders milestone with the correct href', () => { + const { title } = issuable.milestone; + const expected = mergeUrlParams({ milestone_title: title }, TEST_BASE_URL); + + expect(findMilestone().attributes('href')).toBe(expected); + }); + }); + + describe.each` + dueDate | hasClass | desc + ${TEST_MONTH_LATER} | ${false} | ${'with future due date'} + ${TEST_MONTH_AGO} | ${true} | ${'with past due date'} + `('$desc', ({ dueDate, hasClass }) => { + beforeEach(() => { + issuable.due_date = dueDate; + + factory({ issuable }); + }); + + it('renders due date', () => { + expect(findDueDate().exists()).toBe(true); + expect(findDueDate().text()).toBe(formatDate(dueDate, DATE_FORMAT)); + }); + + it(hasClass ? 'has cred class' : 'does not have cred class', () => { + expect(findDueDate().classes('cred')).toEqual(hasClass); + }); + }); + + describe('with labels', () => { + beforeEach(() => { + issuable.labels = [...testLabels]; + + factory({ issuable }); + }); + + it('renders labels', () => { + factory({ issuable }); + + const labels = findLabelLinks().wrappers.map(label => ({ + href: label.attributes('href'), + text: label.text(), + tooltip: label.find('span').attributes('data-original-title'), + })); + + const expected = testLabels.map(label => ({ + href: mergeUrlParams({ 'label_name[]': label.name }, TEST_BASE_URL), + text: label.name, + tooltip: label.description, + })); + + expect(labels).toEqual(expected); + }); + }); + + describe.each` + weight + ${0} + ${10} + ${12345} + `('with weight $weight', ({ weight }) => { + beforeEach(() => { + issuable.weight = weight; + + factory({ issuable }); + }); + + it('renders weight', () => { + expect(findWeight().exists()).toBe(true); + expect(findWeight().text()).toEqual(weight.toString()); + }); + }); + + describe('with closed state', () => { + beforeEach(() => { + issuable.state = 'closed'; + + factory({ issuable }); + }); + + it('renders closed text', () => { + expect(wrapper.text()).toContain(TEXT_CLOSED); + }); + + it('has closed class', () => { + expect(wrapper.classes('closed')).toBe(true); + }); + }); + + describe('with assignees', () => { + beforeEach(() => { + issuable.assignees = testAssignees; + + factory({ issuable }); + }); + + it('renders assignees', () => { + expect(findAssignees().exists()).toBe(true); + expect(findAssignees().props('assignees')).toEqual(testAssignees); + }); + }); + + describe.each` + desc | key | finder + ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount} + ${'with upvote count'} | ${'upvotes'} | ${findUpvotes} + ${'with downvote count'} | ${'downvotes'} | ${findDownvotes} + ${'with notes count'} | ${'user_notes_count'} | ${findNotes} + `('$desc', ({ key, finder }) => { + beforeEach(() => { + issuable[key] = TEST_META_COUNT; + + factory({ issuable }); + }); + + it('renders merge requests count', () => { + expect(finder().exists()).toBe(true); + expect(finder().text()).toBe(TEST_META_COUNT.toString()); + expect(finder().classes('no-comments')).toBe(false); + }); + }); + + describe('with bulk editing', () => { + describe.each` + selected | desc + ${true} | ${'when selected'} + ${false} | ${'when unselected'} + `('$desc', ({ selected }) => { + beforeEach(() => { + factory({ isBulkEditing: true, selected }); + }); + + it(`renders checked is ${selected}`, () => { + expect(findBulkCheckbox().element.checked).toBe(selected); + }); + + it('emits select when clicked', () => { + expect(wrapper.emitted().select).toBeUndefined(); + + findBulkCheckbox().trigger('click'); + + expect(wrapper.emitted().select).toEqual([[{ issuable, selected: !selected }]]); + }); + }); + }); +}); diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js new file mode 100644 index 00000000000..e598a9c5a5d --- /dev/null +++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js @@ -0,0 +1,410 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import flash from '~/flash'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'helpers/test_constants'; +import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue'; +import Issuable from '~/issuables_list/components/issuable.vue'; +import issueablesEventBus from '~/issuables_list/eventhub'; +import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants'; + +jest.mock('~/flash', () => jest.fn()); +jest.mock('~/issuables_list/eventhub'); + +const TEST_LOCATION = `${TEST_HOST}/issues`; +const TEST_ENDPOINT = '/issues'; +const TEST_CREATE_ISSUES_PATH = '/createIssue'; +const TEST_EMPTY_SVG_PATH = '/emptySvg'; + +const localVue = createLocalVue(); + +const MOCK_ISSUES = Array(PAGE_SIZE_MANUAL) + .fill(0) + .map((_, i) => ({ + id: i, + web_url: `url${i}`, + })); + +describe('Issuables list component', () => { + let oldLocation; + let mockAxios; + let wrapper; + let apiSpy; + + const setupApiMock = cb => { + apiSpy = jest.fn(cb); + + mockAxios.onGet(TEST_ENDPOINT).reply(cfg => apiSpy(cfg)); + }; + + const factory = (props = { sortKey: 'priority' }) => { + wrapper = shallowMount(localVue.extend(IssuablesListApp), { + propsData: { + endpoint: TEST_ENDPOINT, + createIssuePath: TEST_CREATE_ISSUES_PATH, + emptySvgPath: TEST_EMPTY_SVG_PATH, + ...props, + }, + localVue, + sync: false, + }); + }; + + const findLoading = () => wrapper.find(GlSkeletonLoading); + const findIssuables = () => wrapper.findAll(Issuable); + const findFirstIssuable = () => findIssuables().wrappers[0]; + const findEmptyState = () => wrapper.find(GlEmptyState); + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + + oldLocation = window.location; + Object.defineProperty(window, 'location', { + writable: true, + value: { href: '', search: '' }, + }); + window.location.href = TEST_LOCATION; + }); + + afterEach(() => { + wrapper.destroy(); + mockAxios.restore(); + jest.clearAllMocks(); + window.location = oldLocation; + }); + + describe('with failed issues response', () => { + beforeEach(() => { + setupApiMock(() => [500]); + + factory(); + + return waitForPromises(); + }); + + it('does not show loading', () => { + expect(wrapper.vm.loading).toBe(false); + }); + + it('flashes an error', () => { + expect(flash).toHaveBeenCalledTimes(1); + }); + }); + + describe('with successful issues response', () => { + beforeEach(() => { + setupApiMock(() => [ + 200, + MOCK_ISSUES.slice(0, PAGE_SIZE), + { + 'x-total': 100, + 'x-page': 2, + }, + ]); + }); + + it('has default props and data', () => { + factory(); + expect(wrapper.vm).toMatchObject({ + // Props + canBulkEdit: false, + createIssuePath: TEST_CREATE_ISSUES_PATH, + emptySvgPath: TEST_EMPTY_SVG_PATH, + + // Data + filters: { + state: 'opened', + }, + isBulkEditing: false, + issuables: [], + loading: true, + page: 1, + selection: {}, + totalItems: 0, + }); + }); + + it('does not call API until mounted', () => { + expect(apiSpy).not.toHaveBeenCalled(); + }); + + describe('when mounted', () => { + beforeEach(() => { + factory(); + }); + + it('calls API', () => { + expect(apiSpy).toHaveBeenCalled(); + }); + + it('shows loading', () => { + expect(findLoading().exists()).toBe(true); + expect(findIssuables().length).toBe(0); + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when finished loading', () => { + beforeEach(() => { + factory(); + + return waitForPromises(); + }); + + it('does not display empty state', () => { + expect(wrapper.vm.issuables.length).toBeGreaterThan(0); + expect(wrapper.vm.emptyState).toEqual({}); + expect(wrapper.contains(GlEmptyState)).toBe(false); + }); + + it('sets the proper page and total items', () => { + expect(wrapper.vm.totalItems).toBe(100); + expect(wrapper.vm.page).toBe(2); + }); + + it('renders one page of issuables and pagination', () => { + expect(findIssuables().length).toBe(PAGE_SIZE); + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + }); + }); + + describe('with bulk editing enabled', () => { + beforeEach(() => { + issueablesEventBus.$on.mockReset(); + issueablesEventBus.$emit.mockReset(); + + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory({ canBulkEdit: true }); + + return waitForPromises(); + }); + + it('is not enabled by default', () => { + expect(wrapper.vm.isBulkEditing).toBe(false); + }); + + it('does not select issues by default', () => { + expect(wrapper.vm.selection).toEqual({}); + }); + + it('"Select All" checkbox toggles all visible issuables"', () => { + wrapper.vm.onSelectAll(); + expect(wrapper.vm.selection).toEqual( + wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), + ); + + wrapper.vm.onSelectAll(); + expect(wrapper.vm.selection).toEqual({}); + }); + + it('"Select All checkbox" selects all issuables if only some are selected"', () => { + wrapper.vm.selection = { [wrapper.vm.issuables[0].id]: true }; + wrapper.vm.onSelectAll(); + expect(wrapper.vm.selection).toEqual( + wrapper.vm.issuables.reduce((acc, i) => ({ ...acc, [i.id]: true }), {}), + ); + }); + + it('selects and deselects issuables', () => { + const [i0, i1, i2] = wrapper.vm.issuables; + + expect(wrapper.vm.selection).toEqual({}); + wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); + expect(wrapper.vm.selection).toEqual({}); + wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true }); + wrapper.vm.onSelectIssuable({ issuable: i0, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true, '0': true }); + wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true }); + wrapper.vm.onSelectIssuable({ issuable: i2, selected: true }); + expect(wrapper.vm.selection).toEqual({ '1': true, '0': true, '2': true }); + wrapper.vm.onSelectIssuable({ issuable: i0, selected: false }); + expect(wrapper.vm.selection).toEqual({ '1': true, '2': true }); + }); + + it('broadcasts a message to the bulk edit sidebar when a value is added to selection', () => { + issueablesEventBus.$emit.mockReset(); + const i1 = wrapper.vm.issuables[1]; + + wrapper.vm.onSelectIssuable({ issuable: i1, selected: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(1); + expect(issueablesEventBus.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit'); + }); + }); + + it('does not broadcast a message to the bulk edit sidebar when a value is not added to selection', () => { + issueablesEventBus.$emit.mockReset(); + + return wrapper.vm + .$nextTick() + .then(waitForPromises) + .then(() => { + const i1 = wrapper.vm.issuables[1]; + + wrapper.vm.onSelectIssuable({ issuable: i1, selected: false }); + }) + .then(wrapper.vm.$nextTick) + .then(() => { + expect(issueablesEventBus.$emit).toHaveBeenCalledTimes(0); + }); + }); + + it('listens to a message to toggle bulk editing', () => { + expect(wrapper.vm.isBulkEditing).toBe(false); + expect(issueablesEventBus.$on.mock.calls[0][0]).toBe('issuables:toggleBulkEdit'); + issueablesEventBus.$on.mock.calls[0][1](true); // Call the message handler + + return waitForPromises() + .then(() => { + expect(wrapper.vm.isBulkEditing).toBe(true); + issueablesEventBus.$on.mock.calls[0][1](false); + }) + .then(() => { + expect(wrapper.vm.isBulkEditing).toBe(false); + }); + }); + }); + + describe('with query params in window.location', () => { + const query = + '?assignee_username=root&author_username=root&confidential=yes&label_name%5B%5D=Aquapod&label_name%5B%5D=Astro&milestone_title=v3.0&my_reaction_emoji=airplane&scope=all&sort=priority&state=opened&utf8=%E2%9C%93&weight=0'; + const expectedFilters = { + assignee_username: 'root', + author_username: 'root', + confidential: 'yes', + my_reaction_emoji: 'airplane', + scope: 'all', + state: 'opened', + utf8: '✓', + weight: '0', + milestone: 'v3.0', + labels: 'Aquapod,Astro', + order_by: 'milestone_due', + sort: 'desc', + }; + + beforeEach(() => { + window.location.href = `${TEST_LOCATION}${query}`; + window.location.search = query; + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory({ sortKey: 'milestone_due_desc' }); + return waitForPromises(); + }); + + it('applies filters and sorts', () => { + expect(wrapper.vm.hasFilters).toBe(true); + expect(wrapper.vm.filters).toEqual(expectedFilters); + + expect(apiSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: { + ...expectedFilters, + with_labels_details: true, + page: 1, + per_page: PAGE_SIZE, + }, + }), + ); + }); + + it('passes the base url to issuable', () => { + expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION); + }); + }); + + describe('with hash in window.location', () => { + beforeEach(() => { + window.location.href = `${TEST_LOCATION}#stuff`; + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory(); + return waitForPromises(); + }); + + it('passes the base url to issuable', () => { + expect(findFirstIssuable().props('baseUrl')).toEqual(TEST_LOCATION); + }); + }); + + describe('with manual sort', () => { + beforeEach(() => { + setupApiMock(() => [200, MOCK_ISSUES.slice(0)]); + factory({ sortKey: RELATIVE_POSITION }); + }); + + it('uses manual page size', () => { + expect(apiSpy).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + per_page: PAGE_SIZE_MANUAL, + }), + }), + ); + }); + }); + + describe('with empty issues response', () => { + beforeEach(() => { + setupApiMock(() => [200, []]); + }); + + describe('with query in window location', () => { + beforeEach(() => { + window.location.search = '?weight=Any'; + + factory(); + + return waitForPromises().then(() => wrapper.vm.$nextTick()); + }); + + it('should display "Sorry, your filter produced no results" if filters are too specific', () => { + expect(findEmptyState().props('title')).toMatchSnapshot(); + }); + }); + + describe('with closed state', () => { + beforeEach(() => { + window.location.search = '?state=closed'; + + factory(); + + return waitForPromises().then(() => wrapper.vm.$nextTick()); + }); + + it('should display a message "There are no closed issues" if there are no closed issues', () => { + expect(findEmptyState().props('title')).toMatchSnapshot(); + }); + }); + + describe('with all state', () => { + beforeEach(() => { + window.location.search = '?state=all'; + + factory(); + + return waitForPromises().then(() => wrapper.vm.$nextTick()); + }); + + it('should display a catch-all if there are no issues to show', () => { + expect(findEmptyState().element).toMatchSnapshot(); + }); + }); + + describe('with empty query', () => { + beforeEach(() => { + factory(); + + return wrapper.vm.$nextTick().then(waitForPromises); + }); + + it('should display the message "There are no open issues"', () => { + expect(findEmptyState().props('title')).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/spec/frontend/issuables_list/issuable_list_test_data.js b/spec/frontend/issuables_list/issuable_list_test_data.js new file mode 100644 index 00000000000..617780fd736 --- /dev/null +++ b/spec/frontend/issuables_list/issuable_list_test_data.js @@ -0,0 +1,72 @@ +export const simpleIssue = { + id: 442, + iid: 31, + title: 'Dismiss Cipher with no integrity', + state: 'opened', + created_at: '2019-08-26T19:06:32.667Z', + updated_at: '2019-08-28T19:53:58.314Z', + labels: [], + milestone: null, + assignees: [], + author: { + id: 3, + name: 'Elnora Bernhard', + username: 'treva.lesch', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/a8c0d9c2882406cf2a9b71494625a796?s=80&d=identicon', + web_url: 'http://localhost:3001/treva.lesch', + }, + assignee: null, + user_notes_count: 0, + merge_requests_count: 0, + upvotes: 0, + downvotes: 0, + due_date: null, + confidential: false, + web_url: 'http://localhost:3001/h5bp/html5-boilerplate/issues/31', + has_tasks: false, + weight: null, +}; + +export const testLabels = [ + { + id: 1, + name: 'Tanuki', + description: 'A cute animal', + color: '#ff0000', + text_color: '#ffffff', + }, + { + id: 2, + name: 'Octocat', + description: 'A grotesque mish-mash of whiskers and tentacles', + color: '#333333', + text_color: '#000000', + }, + { + id: 3, + name: 'scoped::label', + description: 'A scoped label', + color: '#00ff00', + text_color: '#ffffff', + }, +]; + +export const testAssignees = [ + { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + web_url: 'http://localhost:3001/root', + }, + { + id: 22, + name: 'User 0', + username: 'user0', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80&d=identicon', + web_url: 'http://localhost:3001/user0', + }, +]; diff --git a/spec/frontend/issue_show/helpers.js b/spec/frontend/issue_show/helpers.js new file mode 100644 index 00000000000..5d2ced98ae4 --- /dev/null +++ b/spec/frontend/issue_show/helpers.js @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/prefer-default-export +export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => { + const e = new CustomEvent('keydown'); + + e.keyCode = code; + e.metaKey = metaKey; + e.ctrlKey = ctrlKey; + + return e; +}; diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index cc334009982..7c834542a9a 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -60,8 +60,8 @@ describe('Job Log', () => { expect(wrapper.find('.collapsible-line').attributes('role')).toBe('button'); }); - it('renders an icon with the closed state', () => { - expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-right'); + it('renders an icon with the open state', () => { + expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-down'); }); describe('on click header section', () => { diff --git a/spec/frontend/jobs/store/utils_spec.js b/spec/frontend/jobs/store/utils_spec.js index 43dacfe622c..8819f39dee0 100644 --- a/spec/frontend/jobs/store/utils_spec.js +++ b/spec/frontend/jobs/store/utils_spec.js @@ -26,7 +26,7 @@ describe('Jobs Store Utils', () => { const parsedHeaderLine = parseHeaderLine(headerLine, 2); expect(parsedHeaderLine).toEqual({ - isClosed: true, + isClosed: false, isHeader: true, line: { ...headerLine, @@ -57,7 +57,7 @@ describe('Jobs Store Utils', () => { it('adds the section duration to the correct header', () => { const parsed = [ { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'prepare-script', @@ -66,7 +66,7 @@ describe('Jobs Store Utils', () => { lines: [], }, { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'foo-bar', @@ -85,7 +85,7 @@ describe('Jobs Store Utils', () => { it('does not add the section duration when the headers do not match', () => { const parsed = [ { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'bar-foo', @@ -94,7 +94,7 @@ describe('Jobs Store Utils', () => { lines: [], }, { - isClosed: true, + isClosed: false, isHeader: true, line: { section: 'foo-bar', @@ -183,7 +183,7 @@ describe('Jobs Store Utils', () => { describe('collpasible section', () => { it('adds a `isClosed` property', () => { - expect(result[1].isClosed).toEqual(true); + expect(result[1].isClosed).toEqual(false); }); it('adds a `isHeader` property', () => { @@ -213,7 +213,7 @@ describe('Jobs Store Utils', () => { const existingLog = [ { isHeader: true, - isClosed: true, + isClosed: false, line: { content: [{ text: 'bar' }], offset: 10, lineNumber: 1 }, }, ]; @@ -263,7 +263,7 @@ describe('Jobs Store Utils', () => { const existingLog = [ { isHeader: true, - isClosed: true, + isClosed: false, lines: [{ offset: 101, content: [{ text: 'foobar' }], lineNumber: 2 }], line: { offset: 10, @@ -435,7 +435,7 @@ describe('Jobs Store Utils', () => { expect(result).toEqual([ { - isClosed: true, + isClosed: false, isHeader: true, line: { offset: 1, @@ -461,7 +461,7 @@ describe('Jobs Store Utils', () => { expect(result).toEqual([ { - isClosed: true, + isClosed: false, isHeader: true, line: { offset: 1, diff --git a/spec/frontend/lib/utils/chart_utils_spec.js b/spec/frontend/lib/utils/chart_utils_spec.js new file mode 100644 index 00000000000..e811b8405fb --- /dev/null +++ b/spec/frontend/lib/utils/chart_utils_spec.js @@ -0,0 +1,11 @@ +import { firstAndLastY } from '~/lib/utils/chart_utils'; + +describe('Chart utils', () => { + describe('firstAndLastY', () => { + it('returns the first and last y-values of a given data set as an array', () => { + const data = [['', 1], ['', 2], ['', 3]]; + + expect(firstAndLastY(data)).toEqual([1, 3]); + }); + }); +}); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index e2e71229320..ee27789b6b9 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -428,16 +428,57 @@ describe('newDate', () => { }); describe('getDateInPast', () => { - const date = new Date(1563235200000); // 2019-07-16T00:00:00.000Z; + const date = new Date('2019-07-16T00:00:00.000Z'); const daysInPast = 90; it('returns the correct date in the past', () => { const dateInPast = datetimeUtility.getDateInPast(date, daysInPast); - expect(dateInPast).toBe('2019-04-17T00:00:00.000Z'); + const expectedDateInPast = new Date('2019-04-17T00:00:00.000Z'); + + expect(dateInPast).toStrictEqual(expectedDateInPast); }); it('does not modifiy the original date', () => { datetimeUtility.getDateInPast(date, daysInPast); - expect(date).toStrictEqual(new Date(1563235200000)); + expect(date).toStrictEqual(new Date('2019-07-16T00:00:00.000Z')); + }); +}); + +describe('getDatesInRange', () => { + it('returns an empty array if 1st or 2nd argument is not a Date object', () => { + const d1 = new Date('2019-01-01'); + const d2 = 90; + const range = datetimeUtility.getDatesInRange(d1, d2); + + expect(range).toEqual([]); + }); + + it('returns a range of dates between two given dates', () => { + const d1 = new Date('2019-01-01'); + const d2 = new Date('2019-01-31'); + + const range = datetimeUtility.getDatesInRange(d1, d2); + + expect(range.length).toEqual(31); + }); + + it('applies mapper function if provided fro each item in range', () => { + const d1 = new Date('2019-01-01'); + const d2 = new Date('2019-01-31'); + const formatter = date => date.getDate(); + + const range = datetimeUtility.getDatesInRange(d1, d2, formatter); + + range.forEach((formattedItem, index) => { + expect(formattedItem).toEqual(index + 1); + }); + }); +}); + +describe('secondsToMilliseconds', () => { + it('converts seconds to milliseconds correctly', () => { + expect(datetimeUtility.secondsToMilliseconds(0)).toBe(0); + expect(datetimeUtility.secondsToMilliseconds(60)).toBe(60000); + expect(datetimeUtility.secondsToMilliseconds(123)).toBe(123000); }); }); diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js index 381d7c6f8d9..2f8f1092612 100644 --- a/spec/frontend/lib/utils/number_utility_spec.js +++ b/spec/frontend/lib/utils/number_utility_spec.js @@ -7,6 +7,8 @@ import { sum, isOdd, median, + changeInPercent, + formattedChangeInPercent, } from '~/lib/utils/number_utils'; describe('Number Utils', () => { @@ -122,4 +124,42 @@ describe('Number Utils', () => { expect(median(items)).toBe(14.5); }); }); + + describe('changeInPercent', () => { + it.each` + firstValue | secondValue | expectedOutput + ${99} | ${100} | ${1} + ${100} | ${99} | ${-1} + ${0} | ${99} | ${Infinity} + ${2} | ${2} | ${0} + ${-100} | ${-99} | ${1} + `( + 'computes the change between $firstValue and $secondValue in percent', + ({ firstValue, secondValue, expectedOutput }) => { + expect(changeInPercent(firstValue, secondValue)).toBe(expectedOutput); + }, + ); + }); + + describe('formattedChangeInPercent', () => { + it('prepends "%" to the output', () => { + expect(formattedChangeInPercent(1, 2)).toMatch(/%$/); + }); + + it('indicates if the change was a decrease', () => { + expect(formattedChangeInPercent(100, 99)).toContain('-1'); + }); + + it('indicates if the change was an increase', () => { + expect(formattedChangeInPercent(99, 100)).toContain('+1'); + }); + + it('shows "-" per default if the change can not be expressed in an integer', () => { + expect(formattedChangeInPercent(0, 1)).toBe('-'); + }); + + it('shows the given fallback if the change can not be expressed in an integer', () => { + expect(formattedChangeInPercent(0, 1, { nonFiniteResult: '*' })).toBe('*'); + }); + }); }); diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index b6f1aef9ce4..deb6dab772e 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -90,6 +90,19 @@ describe('text_utility', () => { }); }); + describe('convertToSnakeCase', () => { + it.each` + txt | result + ${'snakeCase'} | ${'snake_case'} + ${'snake Case'} | ${'snake_case'} + ${'snake case'} | ${'snake_case'} + ${'snake_case'} | ${'snake_case'} + ${'snakeCasesnake Case'} | ${'snake_casesnake_case'} + `('converts string $txt to $result string', ({ txt, result }) => { + expect(textUtils.convertToSnakeCase(txt)).toEqual(result); + }); + }); + describe('convertToSentenceCase', () => { it('converts Sentence Case to Sentence case', () => { expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world'); diff --git a/spec/frontend/monitoring/charts/time_series_spec.js b/spec/frontend/monitoring/charts/time_series_spec.js new file mode 100644 index 00000000000..554535418fe --- /dev/null +++ b/spec/frontend/monitoring/charts/time_series_spec.js @@ -0,0 +1,397 @@ +import { shallowMount } from '@vue/test-utils'; +import { setTestTimeout } from 'helpers/timeout'; +import { createStore } from '~/monitoring/stores'; +import { GlLink } from '@gitlab/ui'; +import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; +import TimeSeries from '~/monitoring/components/charts/time_series.vue'; +import * as types from '~/monitoring/stores/mutation_types'; +import { + deploymentData, + metricsGroupsAPIResponse, + mockedQueryResultPayload, + mockProjectDir, + mockHost, +} from '../mock_data'; + +import * as iconUtils from '~/lib/utils/icon_utils'; + +const mockSvgPathContent = 'mockSvgPathContent'; +const mockWidgets = 'mockWidgets'; + +jest.mock('~/lib/utils/icon_utils', () => ({ + getSvgIconPathContent: jest.fn().mockImplementation( + () => + new Promise(resolve => { + resolve(mockSvgPathContent); + }), + ), +})); + +describe('Time series component', () => { + let mockGraphData; + let makeTimeSeriesChart; + let store; + + beforeEach(() => { + setTestTimeout(1000); + + store = createStore(); + + store.commit( + `monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`, + metricsGroupsAPIResponse, + ); + + store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData); + + // Mock data contains 2 panels, pick the first one + store.commit(`monitoringDashboard/${types.SET_QUERY_RESULT}`, mockedQueryResultPayload); + + [mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[0].metrics; + + makeTimeSeriesChart = (graphData, type) => + shallowMount(TimeSeries, { + propsData: { + graphData: { ...graphData, type }, + deploymentData: store.state.monitoringDashboard.deploymentData, + projectPath: `${mockHost}${mockProjectDir}`, + }, + slots: { + default: mockWidgets, + }, + sync: false, + store, + attachToDocument: true, + }); + }); + + describe('general functions', () => { + let timeSeriesChart; + + beforeEach(done => { + timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart'); + timeSeriesChart.vm.$nextTick(done); + }); + + it('renders chart title', () => { + expect(timeSeriesChart.find('.js-graph-title').text()).toBe(mockGraphData.title); + }); + + it('contains graph widgets from slot', () => { + expect(timeSeriesChart.find('.js-graph-widgets').text()).toBe(mockWidgets); + }); + + it('allows user to override max value label text using prop', () => { + timeSeriesChart.setProps({ legendMaxText: 'legendMaxText' }); + + expect(timeSeriesChart.props().legendMaxText).toBe('legendMaxText'); + }); + + it('allows user to override average value label text using prop', () => { + timeSeriesChart.setProps({ legendAverageText: 'averageText' }); + + expect(timeSeriesChart.props().legendAverageText).toBe('averageText'); + }); + + describe('methods', () => { + describe('formatTooltipText', () => { + let mockDate; + let mockCommitUrl; + let generateSeriesData; + + beforeEach(() => { + mockDate = deploymentData[0].created_at; + mockCommitUrl = deploymentData[0].commitUrl; + generateSeriesData = type => ({ + seriesData: [ + { + seriesName: timeSeriesChart.vm.chartData[0].name, + componentSubType: type, + value: [mockDate, 5.55555], + dataIndex: 0, + }, + ], + value: mockDate, + }); + }); + + describe('when series is of line type', () => { + beforeEach(done => { + timeSeriesChart.vm.formatTooltipText(generateSeriesData('line')); + timeSeriesChart.vm.$nextTick(done); + }); + + it('formats tooltip title', () => { + expect(timeSeriesChart.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM'); + }); + + it('formats tooltip content', () => { + const name = 'Pod average'; + const value = '5.556'; + const dataIndex = 0; + const seriesLabel = timeSeriesChart.find(GlChartSeriesLabel); + + expect(seriesLabel.vm.color).toBe(''); + expect(shallowWrapperContainsSlotText(seriesLabel, 'default', name)).toBe(true); + expect(timeSeriesChart.vm.tooltip.content).toEqual([ + { name, value, dataIndex, color: undefined }, + ]); + + expect( + shallowWrapperContainsSlotText( + timeSeriesChart.find(GlAreaChart), + 'tooltipContent', + value, + ), + ).toBe(true); + }); + }); + + describe('when series is of scatter type, for deployments', () => { + beforeEach(() => { + timeSeriesChart.vm.formatTooltipText(generateSeriesData('scatter')); + }); + + it('formats tooltip title', () => { + expect(timeSeriesChart.vm.tooltip.title).toBe('16 Jul 2019, 10:14AM'); + }); + + it('formats tooltip sha', () => { + expect(timeSeriesChart.vm.tooltip.sha).toBe('f5bcd1d9'); + }); + + it('formats tooltip commit url', () => { + expect(timeSeriesChart.vm.tooltip.commitUrl).toBe(mockCommitUrl); + }); + }); + }); + + describe('setSvg', () => { + const mockSvgName = 'mockSvgName'; + + beforeEach(done => { + timeSeriesChart.vm.setSvg(mockSvgName); + timeSeriesChart.vm.$nextTick(done); + }); + + it('gets svg path content', () => { + expect(iconUtils.getSvgIconPathContent).toHaveBeenCalledWith(mockSvgName); + }); + + it('sets svg path content', () => { + timeSeriesChart.vm.$nextTick(() => { + expect(timeSeriesChart.vm.svgs[mockSvgName]).toBe(`path://${mockSvgPathContent}`); + }); + }); + + it('contains an svg object within an array to properly render icon', () => { + timeSeriesChart.vm.$nextTick(() => { + expect(timeSeriesChart.vm.chartOptions.dataZoom).toEqual([ + { + handleIcon: `path://${mockSvgPathContent}`, + }, + ]); + }); + }); + }); + + describe('onResize', () => { + const mockWidth = 233; + + beforeEach(() => { + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({ + width: mockWidth, + })); + timeSeriesChart.vm.onResize(); + }); + + it('sets area chart width', () => { + expect(timeSeriesChart.vm.width).toBe(mockWidth); + }); + }); + }); + + describe('computed', () => { + describe('chartData', () => { + let chartData; + const seriesData = () => chartData[0]; + + beforeEach(() => { + ({ chartData } = timeSeriesChart.vm); + }); + + it('utilizes all data points', () => { + const { values } = mockGraphData.queries[0].result[0]; + + expect(chartData.length).toBe(1); + expect(seriesData().data.length).toBe(values.length); + }); + + it('creates valid data', () => { + const { data } = seriesData(); + + expect( + data.filter( + ([time, value]) => new Date(time).getTime() > 0 && typeof value === 'number', + ).length, + ).toBe(data.length); + }); + + it('formats line width correctly', () => { + expect(chartData[0].lineStyle.width).toBe(2); + }); + }); + + describe('chartOptions', () => { + describe('are extended by `option`', () => { + const mockSeriesName = 'Extra series 1'; + const mockOption = { + option1: 'option1', + option2: 'option2', + }; + + it('arbitrary options', () => { + timeSeriesChart.setProps({ + option: mockOption, + }); + + expect(timeSeriesChart.vm.chartOptions).toEqual(expect.objectContaining(mockOption)); + }); + + it('additional series', () => { + timeSeriesChart.setProps({ + option: { + series: [ + { + name: mockSeriesName, + }, + ], + }, + }); + + const optionSeries = timeSeriesChart.vm.chartOptions.series; + + expect(optionSeries.length).toEqual(2); + expect(optionSeries[0].name).toEqual(mockSeriesName); + }); + }); + + describe('yAxis formatter', () => { + let format; + + beforeEach(() => { + format = timeSeriesChart.vm.chartOptions.yAxis.axisLabel.formatter; + }); + + it('rounds to 3 decimal places', () => { + expect(format(0.88888)).toBe('0.889'); + }); + }); + }); + + describe('scatterSeries', () => { + it('utilizes deployment data', () => { + expect(timeSeriesChart.vm.scatterSeries.data).toEqual([ + ['2019-07-16T10:14:25.589Z', 0], + ['2019-07-16T11:14:25.589Z', 0], + ['2019-07-16T12:14:25.589Z', 0], + ]); + + expect(timeSeriesChart.vm.scatterSeries.symbolSize).toBe(14); + }); + }); + + describe('yAxisLabel', () => { + it('constructs a label for the chart y-axis', () => { + expect(timeSeriesChart.vm.yAxisLabel).toBe('Memory Used per Pod'); + }); + }); + }); + + afterEach(() => { + timeSeriesChart.destroy(); + }); + }); + + describe('wrapped components', () => { + const glChartComponents = [ + { + chartType: 'area-chart', + component: GlAreaChart, + }, + { + chartType: 'line-chart', + component: GlLineChart, + }, + ]; + + glChartComponents.forEach(dynamicComponent => { + describe(`GitLab UI: ${dynamicComponent.chartType}`, () => { + let timeSeriesAreaChart; + let glChart; + + beforeEach(done => { + timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType); + glChart = timeSeriesAreaChart.find(dynamicComponent.component); + timeSeriesAreaChart.vm.$nextTick(done); + }); + + afterEach(() => { + timeSeriesAreaChart.destroy(); + }); + + it('is a Vue instance', () => { + expect(glChart.exists()).toBe(true); + expect(glChart.isVueInstance()).toBe(true); + }); + + it('receives data properties needed for proper chart render', () => { + const props = glChart.props(); + + expect(props.data).toBe(timeSeriesAreaChart.vm.chartData); + expect(props.option).toBe(timeSeriesAreaChart.vm.chartOptions); + expect(props.formatTooltipText).toBe(timeSeriesAreaChart.vm.formatTooltipText); + expect(props.thresholds).toBe(timeSeriesAreaChart.vm.thresholds); + }); + + it('recieves a tooltip title', done => { + const mockTitle = 'mockTitle'; + timeSeriesAreaChart.vm.tooltip.title = mockTitle; + + timeSeriesAreaChart.vm.$nextTick(() => { + expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', mockTitle)).toBe(true); + done(); + }); + }); + + describe('when tooltip is showing deployment data', () => { + const mockSha = 'mockSha'; + const commitUrl = `${mockProjectDir}/commit/${mockSha}`; + + beforeEach(done => { + timeSeriesAreaChart.vm.tooltip.isDeployment = true; + timeSeriesAreaChart.vm.$nextTick(done); + }); + + it('uses deployment title', () => { + expect(shallowWrapperContainsSlotText(glChart, 'tooltipTitle', 'Deployed')).toBe(true); + }); + + it('renders clickable commit sha in tooltip content', done => { + timeSeriesAreaChart.vm.tooltip.sha = mockSha; + timeSeriesAreaChart.vm.tooltip.commitUrl = commitUrl; + + timeSeriesAreaChart.vm.$nextTick(() => { + const commitLink = timeSeriesAreaChart.find(GlLink); + + expect(shallowWrapperContainsSlotText(commitLink, 'default', mockSha)).toBe(true); + expect(commitLink.attributes('href')).toEqual(commitUrl); + done(); + }); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js new file mode 100644 index 00000000000..6707d0b1fe8 --- /dev/null +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -0,0 +1,303 @@ +import Anomaly from '~/monitoring/components/charts/anomaly.vue'; + +import { shallowMount } from '@vue/test-utils'; +import { colorValues } from '~/monitoring/constants'; +import { + anomalyDeploymentData, + mockProjectDir, + anomalyMockGraphData, + anomalyMockResultValues, +} from '../../mock_data'; +import { TEST_HOST } from 'helpers/test_constants'; +import MonitorTimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; + +const mockWidgets = 'mockWidgets'; +const mockProjectPath = `${TEST_HOST}${mockProjectDir}`; + +jest.mock('~/lib/utils/icon_utils'); // mock getSvgIconPathContent + +const makeAnomalyGraphData = (datasetName, template = anomalyMockGraphData) => { + const queries = anomalyMockResultValues[datasetName].map((values, index) => ({ + ...template.queries[index], + result: [ + { + metrics: {}, + values, + }, + ], + })); + return { ...template, queries }; +}; + +describe('Anomaly chart component', () => { + let wrapper; + + const setupAnomalyChart = props => { + wrapper = shallowMount(Anomaly, { + propsData: { ...props }, + slots: { + default: mockWidgets, + }, + sync: false, + }); + }; + const findTimeSeries = () => wrapper.find(MonitorTimeSeriesChart); + const getTimeSeriesProps = () => findTimeSeries().props(); + + describe('wrapped monitor-time-series-chart component', () => { + const dataSetName = 'noAnomaly'; + const dataSet = anomalyMockResultValues[dataSetName]; + const inputThresholds = ['some threshold']; + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + thresholds: inputThresholds, + projectPath: mockProjectPath, + }); + }); + + it('is a Vue instance', () => { + expect(findTimeSeries().exists()).toBe(true); + expect(findTimeSeries().isVueInstance()).toBe(true); + }); + + describe('receives props correctly', () => { + describe('graph-data', () => { + it('receives a single "metric" series', () => { + const { graphData } = getTimeSeriesProps(); + expect(graphData.queries.length).toBe(1); + }); + + it('receives "metric" with all data', () => { + const { graphData } = getTimeSeriesProps(); + const query = graphData.queries[0]; + const expectedQuery = makeAnomalyGraphData(dataSetName).queries[0]; + expect(query).toEqual(expectedQuery); + }); + + it('receives the "metric" results', () => { + const { graphData } = getTimeSeriesProps(); + const { result } = graphData.queries[0]; + const { values } = result[0]; + const [metricDataset] = dataSet; + expect(values).toEqual(expect.any(Array)); + + values.forEach(([, y], index) => { + expect(y).toBeCloseTo(metricDataset[index][1]); + }); + }); + }); + + describe('option', () => { + let option; + let series; + + beforeEach(() => { + ({ option } = getTimeSeriesProps()); + ({ series } = option); + }); + + it('contains a boundary band', () => { + expect(series).toEqual(expect.any(Array)); + expect(series.length).toEqual(2); // 1 upper + 1 lower boundaries + expect(series[0].stack).toEqual(series[1].stack); + + series.forEach(s => { + expect(s.type).toBe('line'); + expect(s.lineStyle.width).toBe(0); + expect(s.lineStyle.color).toMatch(/rgba\(.+\)/); + expect(s.lineStyle.color).toMatch(s.color); + expect(s.symbol).toEqual('none'); + }); + }); + + it('upper boundary values are stacked on top of lower boundary', () => { + const [lowerSeries, upperSeries] = series; + const [, upperDataset, lowerDataset] = dataSet; + + lowerSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(lowerDataset[i][1]); + }); + + upperSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + }); + }); + }); + + describe('series-config', () => { + let seriesConfig; + + beforeEach(() => { + ({ seriesConfig } = getTimeSeriesProps()); + }); + + it('display symbols is enabled', () => { + expect(seriesConfig).toEqual( + expect.objectContaining({ + type: 'line', + symbol: 'circle', + showSymbol: true, + symbolSize: expect.any(Function), + itemStyle: { + color: expect.any(Function), + }, + }), + ); + }); + it('does not display anomalies', () => { + const { symbolSize, itemStyle } = seriesConfig; + const [metricDataset] = dataSet; + + metricDataset.forEach((v, dataIndex) => { + const size = symbolSize(null, { dataIndex }); + const color = itemStyle.color({ dataIndex }); + + // normal color and small size + expect(size).toBeCloseTo(0); + expect(color).toBe(colorValues.primaryColor); + }); + }); + + it('can format y values (to use in tooltips)', () => { + expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]); + expect(parseFloat(wrapper.vm.yValueFormatted(1, 0))).toEqual(dataSet[1][0][1]); + expect(parseFloat(wrapper.vm.yValueFormatted(2, 0))).toEqual(dataSet[2][0][1]); + }); + }); + + describe('inherited properties', () => { + it('"deployment-data" keeps the same value', () => { + const { deploymentData } = getTimeSeriesProps(); + expect(deploymentData).toEqual(anomalyDeploymentData); + }); + it('"thresholds" keeps the same value', () => { + const { thresholds } = getTimeSeriesProps(); + expect(thresholds).toEqual(inputThresholds); + }); + it('"projectPath" keeps the same value', () => { + const { projectPath } = getTimeSeriesProps(); + expect(projectPath).toEqual(mockProjectPath); + }); + }); + }); + }); + + describe('with no boundary data', () => { + const dataSetName = 'noBoundary'; + const dataSet = anomalyMockResultValues[dataSetName]; + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + }); + }); + + describe('option', () => { + let option; + let series; + + beforeEach(() => { + ({ option } = getTimeSeriesProps()); + ({ series } = option); + }); + + it('does not display a boundary band', () => { + expect(series).toEqual(expect.any(Array)); + expect(series.length).toEqual(0); // no boundaries + }); + + it('can format y values (to use in tooltips)', () => { + expect(parseFloat(wrapper.vm.yValueFormatted(0, 0))).toEqual(dataSet[0][0][1]); + expect(wrapper.vm.yValueFormatted(1, 0)).toBe(''); // missing boundary + expect(wrapper.vm.yValueFormatted(2, 0)).toBe(''); // missing boundary + }); + }); + }); + + describe('with one anomaly', () => { + const dataSetName = 'oneAnomaly'; + const dataSet = anomalyMockResultValues[dataSetName]; + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + }); + }); + + describe('series-config', () => { + it('displays one anomaly', () => { + const { seriesConfig } = getTimeSeriesProps(); + const { symbolSize, itemStyle } = seriesConfig; + const [metricDataset] = dataSet; + + const bigDots = metricDataset.filter((v, dataIndex) => { + const size = symbolSize(null, { dataIndex }); + return size > 0.1; + }); + const redDots = metricDataset.filter((v, dataIndex) => { + const color = itemStyle.color({ dataIndex }); + return color === colorValues.anomalySymbol; + }); + + expect(bigDots.length).toBe(1); + expect(redDots.length).toBe(1); + }); + }); + }); + + describe('with offset', () => { + const dataSetName = 'negativeBoundary'; + const dataSet = anomalyMockResultValues[dataSetName]; + const expectedOffset = 4; // Lowst point in mock data is -3.70, it gets rounded + + beforeEach(() => { + setupAnomalyChart({ + graphData: makeAnomalyGraphData(dataSetName), + deploymentData: anomalyDeploymentData, + }); + }); + + describe('receives props correctly', () => { + describe('graph-data', () => { + it('receives a single "metric" series', () => { + const { graphData } = getTimeSeriesProps(); + expect(graphData.queries.length).toBe(1); + }); + + it('receives "metric" results and applies the offset to them', () => { + const { graphData } = getTimeSeriesProps(); + const { result } = graphData.queries[0]; + const { values } = result[0]; + const [metricDataset] = dataSet; + expect(values).toEqual(expect.any(Array)); + + values.forEach(([, y], index) => { + expect(y).toBeCloseTo(metricDataset[index][1] + expectedOffset); + }); + }); + }); + }); + + describe('option', () => { + it('upper boundary values are stacked on top of lower boundary, plus the offset', () => { + const { option } = getTimeSeriesProps(); + const { series } = option; + const [lowerSeries, upperSeries] = series; + const [, upperDataset, lowerDataset] = dataSet; + + lowerSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(lowerDataset[i][1] + expectedOffset); + }); + + upperSeries.data.forEach(([, y], i) => { + expect(y).toBeCloseTo(upperDataset[i][1] - lowerDataset[i][1]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js index be544435671..ca05461c8cf 100644 --- a/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/monitoring/components/date_time_picker/date_time_picker_spec.js @@ -51,6 +51,16 @@ describe('DateTimePicker', () => { }); }); + it('renders dropdown without a selectedTimeWindow set', done => { + createComponent({ + selectedTimeWindow: {}, + }); + dateTimePicker.vm.$nextTick(() => { + expect(dateTimePicker.findAll('input').length).toBe(2); + done(); + }); + }); + it('renders inputs with h/m/s truncated if its all 0s', done => { createComponent({ selectedTimeWindow: { diff --git a/spec/frontend/monitoring/embed/embed_spec.js b/spec/frontend/monitoring/embed/embed_spec.js index 5de1a7c4c3b..3e22b0858e6 100644 --- a/spec/frontend/monitoring/embed/embed_spec.js +++ b/spec/frontend/monitoring/embed/embed_spec.js @@ -61,8 +61,8 @@ describe('Embed', () => { describe('metrics are available', () => { beforeEach(() => { - store.state.monitoringDashboard.groups = groups; - store.state.monitoringDashboard.groups[0].metrics = metricsData; + store.state.monitoringDashboard.dashboard.panel_groups = groups; + store.state.monitoringDashboard.dashboard.panel_groups[0].metrics = metricsData; store.state.monitoringDashboard.metricsWithData = metricsWithData; mountComponent(); diff --git a/spec/frontend/monitoring/embed/mock_data.js b/spec/frontend/monitoring/embed/mock_data.js index df4acb82e95..1685021fd4b 100644 --- a/spec/frontend/monitoring/embed/mock_data.js +++ b/spec/frontend/monitoring/embed/mock_data.js @@ -81,7 +81,9 @@ export const metricsData = [ export const initialState = { monitoringDashboard: {}, - groups: [], + dashboard: { + panel_groups: [], + }, metricsWithData: [], useDashboardEndpoint: true, }; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js new file mode 100644 index 00000000000..c42366ab484 --- /dev/null +++ b/spec/frontend/monitoring/mock_data.js @@ -0,0 +1,465 @@ +export const mockHost = 'http://test.host'; +export const mockProjectDir = '/frontend-fixtures/environments-project'; + +export const anomalyDeploymentData = [ + { + id: 111, + iid: 3, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-08-19T22:00:00.000Z', + deployed_at: '2019-08-19T22:01:00.000Z', + tag: false, + 'last?': true, + }, + { + id: 110, + iid: 2, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-08-19T23:00:00.000Z', + deployed_at: '2019-08-19T23:00:00.000Z', + tag: false, + 'last?': false, + }, +]; + +export const anomalyMockResultValues = { + noAnomaly: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 1.45], + ['2019-08-19T21:00:00.000Z', 1.55], + ['2019-08-19T22:00:00.000Z', 1.48], + ], + [ + // upper boundary + ['2019-08-19T19:00:00.000Z', 2], + ['2019-08-19T20:00:00.000Z', 2.55], + ['2019-08-19T21:00:00.000Z', 2.65], + ['2019-08-19T22:00:00.000Z', 3.0], + ], + [ + // lower boundary + ['2019-08-19T19:00:00.000Z', 0.45], + ['2019-08-19T20:00:00.000Z', 0.65], + ['2019-08-19T21:00:00.000Z', 0.7], + ['2019-08-19T22:00:00.000Z', 0.8], + ], + ], + noBoundary: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 1.45], + ['2019-08-19T21:00:00.000Z', 1.55], + ['2019-08-19T22:00:00.000Z', 1.48], + ], + [ + // empty upper boundary + ], + [ + // empty lower boundary + ], + ], + oneAnomaly: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 3.45], // anomaly + ['2019-08-19T21:00:00.000Z', 1.55], + ], + [ + // upper boundary + ['2019-08-19T19:00:00.000Z', 2], + ['2019-08-19T20:00:00.000Z', 2.55], + ['2019-08-19T21:00:00.000Z', 2.65], + ], + [ + // lower boundary + ['2019-08-19T19:00:00.000Z', 0.45], + ['2019-08-19T20:00:00.000Z', 0.65], + ['2019-08-19T21:00:00.000Z', 0.7], + ], + ], + negativeBoundary: [ + [ + ['2019-08-19T19:00:00.000Z', 1.25], + ['2019-08-19T20:00:00.000Z', 3.45], // anomaly + ['2019-08-19T21:00:00.000Z', 1.55], + ], + [ + // upper boundary + ['2019-08-19T19:00:00.000Z', 2], + ['2019-08-19T20:00:00.000Z', 2.55], + ['2019-08-19T21:00:00.000Z', 2.65], + ], + [ + // lower boundary + ['2019-08-19T19:00:00.000Z', -1.25], + ['2019-08-19T20:00:00.000Z', -2.65], + ['2019-08-19T21:00:00.000Z', -3.7], // lowest point + ], + ], +}; + +export const anomalyMockGraphData = { + title: 'Requests Per Second Mock Data', + type: 'anomaly-chart', + weight: 3, + metrics: [ + // Not used + ], + queries: [ + { + metricId: '90', + id: 'metric', + query_range: 'MOCK_PROMETHEUS_METRIC_QUERY_RANGE', + unit: 'RPS', + label: 'Metrics RPS', + metric_id: 90, + prometheus_endpoint_path: 'MOCK_METRIC_PEP', + result: [ + { + metric: {}, + values: [['2019-08-19T19:00:00.000Z', 0]], + }, + ], + }, + { + metricId: '91', + id: 'upper', + query_range: '...', + unit: 'RPS', + label: 'Upper Limit Metrics RPS', + metric_id: 91, + prometheus_endpoint_path: 'MOCK_UPPER_PEP', + result: [ + { + metric: {}, + values: [['2019-08-19T19:00:00.000Z', 0]], + }, + ], + }, + { + metricId: '92', + id: 'lower', + query_range: '...', + unit: 'RPS', + label: 'Lower Limit Metrics RPS', + metric_id: 92, + prometheus_endpoint_path: 'MOCK_LOWER_PEP', + result: [ + { + metric: {}, + values: [['2019-08-19T19:00:00.000Z', 0]], + }, + ], + }, + ], +}; + +export const deploymentData = [ + { + id: 111, + iid: 3, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + commitUrl: + 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-07-16T10:14:25.589Z', + tag: false, + tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', + 'last?': true, + }, + { + id: 110, + iid: 2, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + commitUrl: + 'http://test.host/frontend-fixtures/environments-project/commit/f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master', + }, + created_at: '2019-07-16T11:14:25.589Z', + tag: false, + tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', + 'last?': false, + }, + { + id: 109, + iid: 1, + sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2', + commitUrl: + 'http://test.host/frontend-fixtures/environments-project/commit/6511e58faafaa7ad2228990ec57f19d66f7db7c2', + ref: { + name: 'update2-readme', + }, + created_at: '2019-07-16T12:14:25.589Z', + tag: false, + tagUrl: 'http://test.host/frontend-fixtures/environments-project/tags/false', + 'last?': false, + }, +]; + +export const metricsNewGroupsAPIResponse = [ + { + group: 'System metrics (Kubernetes)', + priority: 5, + panels: [ + { + title: 'Memory Usage (Pod average)', + type: 'area-chart', + y_label: 'Memory Used per Pod', + weight: 2, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_average', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', + label: 'Pod average', + unit: 'MB', + metric_id: 17, + prometheus_endpoint_path: + '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', + appearance: { + line: { + width: 2, + }, + }, + }, + ], + }, + ], + }, +]; + +export const mockedQueryResultPayload = { + metricId: '17_system_metrics_kubernetes_container_memory_average', + result: [ + { + metric: {}, + values: [ + [1563272065.589, '10.396484375'], + [1563272125.589, '10.333984375'], + [1563272185.589, '10.333984375'], + [1563272245.589, '10.333984375'], + [1563272305.589, '10.333984375'], + [1563272365.589, '10.333984375'], + [1563272425.589, '10.38671875'], + [1563272485.589, '10.333984375'], + [1563272545.589, '10.333984375'], + [1563272605.589, '10.333984375'], + [1563272665.589, '10.333984375'], + [1563272725.589, '10.333984375'], + [1563272785.589, '10.396484375'], + [1563272845.589, '10.333984375'], + [1563272905.589, '10.333984375'], + [1563272965.589, '10.3984375'], + [1563273025.589, '10.337890625'], + [1563273085.589, '10.34765625'], + [1563273145.589, '10.337890625'], + [1563273205.589, '10.337890625'], + [1563273265.589, '10.337890625'], + [1563273325.589, '10.337890625'], + [1563273385.589, '10.337890625'], + [1563273445.589, '10.337890625'], + [1563273505.589, '10.337890625'], + [1563273565.589, '10.337890625'], + [1563273625.589, '10.337890625'], + [1563273685.589, '10.337890625'], + [1563273745.589, '10.337890625'], + [1563273805.589, '10.337890625'], + [1563273865.589, '10.390625'], + [1563273925.589, '10.390625'], + ], + }, + ], +}; + +export const metricsGroupsAPIResponse = [ + { + group: 'System metrics (Kubernetes)', + priority: 5, + panels: [ + { + title: 'Memory Usage (Pod average)', + type: 'area-chart', + y_label: 'Memory Used per Pod', + weight: 2, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_average', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', + label: 'Pod average', + unit: 'MB', + metric_id: 17, + prometheus_endpoint_path: + '/root/autodevops-deploy/environments/32/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', + appearance: { + line: { + width: 2, + }, + }, + }, + ], + }, + { + title: 'Core Usage (Total)', + type: 'area-chart', + y_label: 'Total Cores', + weight: 3, + metrics: [ + { + id: 'system_metrics_kubernetes_container_cores_total', + query_range: + 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)', + label: 'Total', + unit: 'cores', + metric_id: 13, + }, + ], + }, + ], + }, +]; + +export const environmentData = [ + { + id: 34, + name: 'production', + state: 'available', + external_url: 'http://root-autodevops-deploy.my-fake-domain.com', + environment_type: null, + stop_action: false, + metrics_path: '/root/hello-prometheus/environments/34/metrics', + environment_path: '/root/hello-prometheus/environments/34', + stop_path: '/root/hello-prometheus/environments/34/stop', + terminal_path: '/root/hello-prometheus/environments/34/terminal', + folder_path: '/root/hello-prometheus/environments/folders/production', + created_at: '2018-06-29T16:53:38.301Z', + updated_at: '2018-06-29T16:57:09.825Z', + last_deployment: { + id: 127, + }, + }, + { + id: 35, + name: 'review/noop-branch', + state: 'available', + external_url: 'http://root-autodevops-deploy-review-noop-branc-die93w.my-fake-domain.com', + environment_type: 'review', + stop_action: true, + metrics_path: '/root/hello-prometheus/environments/35/metrics', + environment_path: '/root/hello-prometheus/environments/35', + stop_path: '/root/hello-prometheus/environments/35/stop', + terminal_path: '/root/hello-prometheus/environments/35/terminal', + folder_path: '/root/hello-prometheus/environments/folders/review', + created_at: '2018-07-03T18:39:41.702Z', + updated_at: '2018-07-03T18:44:54.010Z', + last_deployment: { + id: 128, + }, + }, + { + id: 36, + name: 'no-deployment/noop-branch', + state: 'available', + created_at: '2018-07-04T18:39:41.702Z', + updated_at: '2018-07-04T18:44:54.010Z', + }, +]; + +export const metricsDashboardResponse = { + dashboard: { + dashboard: 'Environment metrics', + priority: 1, + panel_groups: [ + { + group: 'System metrics (Kubernetes)', + priority: 5, + panels: [ + { + title: 'Memory Usage (Total)', + type: 'area-chart', + y_label: 'Total Memory Used', + weight: 4, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_total', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) /1024/1024/1024', + label: 'Total', + unit: 'GB', + metric_id: 12, + prometheus_endpoint_path: 'http://test', + }, + ], + }, + { + title: 'Core Usage (Total)', + type: 'area-chart', + y_label: 'Total Cores', + weight: 3, + metrics: [ + { + id: 'system_metrics_kubernetes_container_cores_total', + query_range: + 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)', + label: 'Total', + unit: 'cores', + metric_id: 13, + }, + ], + }, + { + title: 'Memory Usage (Pod average)', + type: 'line-chart', + y_label: 'Memory Used per Pod', + weight: 2, + metrics: [ + { + id: 'system_metrics_kubernetes_container_memory_average', + query_range: + 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024', + label: 'Pod average', + unit: 'MB', + metric_id: 14, + }, + ], + }, + ], + }, + ], + }, + status: 'success', +}; + +export const dashboardGitResponse = [ + { + default: true, + display_name: 'Default', + can_edit: false, + project_blob_path: null, + path: 'config/prometheus/common_metrics.yml', + }, + { + default: false, + display_name: 'Custom Dashboard 1', + can_edit: true, + project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_1.yml`, + path: '.gitlab/dashboards/dashboard_1.yml', + }, + { + default: false, + display_name: 'Custom Dashboard 2', + can_edit: true, + project_blob_path: `${mockProjectDir}/blob/master/dashboards/.gitlab/dashboards/dashboard_2.yml`, + path: '.gitlab/dashboards/dashboard_2.yml', + }, +]; diff --git a/spec/frontend/monitoring/panel_type_spec.js b/spec/frontend/monitoring/panel_type_spec.js new file mode 100644 index 00000000000..54a63e7f61f --- /dev/null +++ b/spec/frontend/monitoring/panel_type_spec.js @@ -0,0 +1,166 @@ +import { shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { setTestTimeout } from 'helpers/timeout'; +import axios from '~/lib/utils/axios_utils'; +import PanelType from '~/monitoring/components/panel_type.vue'; +import EmptyChart from '~/monitoring/components/charts/empty_chart.vue'; +import TimeSeriesChart from '~/monitoring/components/charts/time_series.vue'; +import AnomalyChart from '~/monitoring/components/charts/anomaly.vue'; +import { graphDataPrometheusQueryRange } from '../../javascripts/monitoring/mock_data'; +import { anomalyMockGraphData } from '../../frontend/monitoring/mock_data'; +import { createStore } from '~/monitoring/stores'; + +global.IS_EE = true; +global.URL.createObjectURL = jest.fn(); + +describe('Panel Type component', () => { + let axiosMock; + let store; + let panelType; + const dashboardWidth = 100; + const exampleText = 'example_text'; + + beforeEach(() => { + setTestTimeout(1000); + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.reset(); + }); + + describe('When no graphData is available', () => { + let glEmptyChart; + // Deep clone object before modifying + const graphDataNoResult = JSON.parse(JSON.stringify(graphDataPrometheusQueryRange)); + graphDataNoResult.queries[0].result = []; + + beforeEach(() => { + panelType = shallowMount(PanelType, { + propsData: { + clipboardText: 'dashboard_link', + dashboardWidth, + graphData: graphDataNoResult, + }, + sync: false, + attachToDocument: true, + }); + }); + + afterEach(() => { + panelType.destroy(); + }); + + describe('Empty Chart component', () => { + beforeEach(() => { + glEmptyChart = panelType.find(EmptyChart); + }); + + it('is a Vue instance', () => { + expect(glEmptyChart.isVueInstance()).toBe(true); + }); + + it('it receives a graph title', () => { + const props = glEmptyChart.props(); + + expect(props.graphTitle).toBe(panelType.vm.graphData.title); + }); + }); + }); + + describe('when Graph data is available', () => { + const propsData = { + clipboardText: exampleText, + dashboardWidth, + graphData: graphDataPrometheusQueryRange, + }; + + beforeEach(done => { + store = createStore(); + panelType = shallowMount(PanelType, { + propsData, + store, + sync: false, + attachToDocument: true, + }); + panelType.vm.$nextTick(done); + }); + + afterEach(() => { + panelType.destroy(); + }); + + describe('Time Series Chart panel type', () => { + it('is rendered', () => { + expect(panelType.find(TimeSeriesChart).isVueInstance()).toBe(true); + expect(panelType.find(TimeSeriesChart).exists()).toBe(true); + }); + + it('sets clipboard text on the dropdown', () => { + const link = () => panelType.find('.js-chart-link'); + const clipboardText = () => link().element.dataset.clipboardText; + + expect(clipboardText()).toBe(exampleText); + }); + }); + + describe('Anomaly Chart panel type', () => { + beforeEach(done => { + panelType.setProps({ + graphData: anomalyMockGraphData, + }); + panelType.vm.$nextTick(done); + }); + + it('is rendered with an anomaly chart', () => { + expect(panelType.find(AnomalyChart).isVueInstance()).toBe(true); + expect(panelType.find(AnomalyChart).exists()).toBe(true); + }); + }); + }); + + describe('when downloading metrics data as CSV', () => { + beforeEach(done => { + graphDataPrometheusQueryRange.y_label = 'metric'; + store = createStore(); + panelType = shallowMount(PanelType, { + propsData: { + clipboardText: exampleText, + dashboardWidth, + graphData: graphDataPrometheusQueryRange, + }, + store, + sync: false, + attachToDocument: true, + }); + panelType.vm.$nextTick(done); + }); + + afterEach(() => { + panelType.destroy(); + }); + + describe('csvText', () => { + it('converts metrics data from json to csv', () => { + const header = `timestamp,${graphDataPrometheusQueryRange.y_label}`; + const data = graphDataPrometheusQueryRange.queries[0].result[0].values; + const firstRow = `${data[0][0]},${data[0][1]}`; + const secondRow = `${data[1][0]},${data[1][1]}`; + + expect(panelType.vm.csvText).toBe(`${header}\r\n${firstRow}\r\n${secondRow}\r\n`); + }); + }); + + describe('downloadCsv', () => { + it('produces a link with a Blob', () => { + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(expect.any(Blob)); + expect(global.URL.createObjectURL).toHaveBeenLastCalledWith( + expect.objectContaining({ + size: panelType.vm.csvText.length, + type: 'text/plain', + }), + ); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js new file mode 100644 index 00000000000..d4bc613ffea --- /dev/null +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -0,0 +1,416 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; + +import store from '~/monitoring/stores'; +import * as types from '~/monitoring/stores/mutation_types'; +import { + backOffRequest, + fetchDashboard, + receiveMetricsDashboardSuccess, + receiveMetricsDashboardFailure, + fetchDeploymentsData, + fetchEnvironmentsData, + fetchPrometheusMetrics, + fetchPrometheusMetric, + requestMetricsData, + setEndpoints, + setGettingStartedEmptyState, +} from '~/monitoring/stores/actions'; +import storeState from '~/monitoring/stores/state'; +import { + deploymentData, + environmentData, + metricsDashboardResponse, + metricsGroupsAPIResponse, + dashboardGitResponse, +} from '../mock_data'; + +jest.mock('~/lib/utils/common_utils'); + +const resetStore = str => { + str.replaceState({ + showEmptyState: true, + emptyState: 'loading', + groups: [], + }); +}; + +const MAX_REQUESTS = 3; + +describe('Monitoring store helpers', () => { + let mock; + + // Mock underlying `backOff` function to remove in-built delay. + backOff.mockImplementation( + callback => + new Promise((resolve, reject) => { + const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); + const next = () => callback(next, stop); + callback(next, stop); + }), + ); + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('backOffRequest', () => { + it('returns immediately when recieving a 200 status code', () => { + mock.onGet(TEST_HOST).reply(200); + + return backOffRequest(() => axios.get(TEST_HOST)).then(() => { + expect(mock.history.get.length).toBe(1); + }); + }); + + it(`repeats the network call ${MAX_REQUESTS} times when receiving a 204 response`, done => { + mock.onGet(TEST_HOST).reply(statusCodes.NO_CONTENT, {}); + + backOffRequest(() => axios.get(TEST_HOST)) + .then(done.fail) + .catch(() => { + expect(mock.history.get.length).toBe(MAX_REQUESTS); + done(); + }); + }); + }); +}); + +describe('Monitoring store actions', () => { + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { + resetStore(store); + mock.restore(); + }); + describe('requestMetricsData', () => { + it('sets emptyState to loading', () => { + const commit = jest.fn(); + const { state } = store; + requestMetricsData({ + state, + commit, + }); + expect(commit).toHaveBeenCalledWith(types.REQUEST_METRICS_DATA); + }); + }); + describe('fetchDeploymentsData', () => { + it('commits RECEIVE_DEPLOYMENTS_DATA_SUCCESS on error', done => { + const dispatch = jest.fn(); + const { state } = store; + state.deploymentsEndpoint = '/success'; + mock.onGet(state.deploymentsEndpoint).reply(200, { + deployments: deploymentData, + }); + fetchDeploymentsData({ + state, + dispatch, + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataSuccess', deploymentData); + done(); + }) + .catch(done.fail); + }); + it('commits RECEIVE_DEPLOYMENTS_DATA_FAILURE on error', done => { + const dispatch = jest.fn(); + const { state } = store; + state.deploymentsEndpoint = '/error'; + mock.onGet(state.deploymentsEndpoint).reply(500); + fetchDeploymentsData({ + state, + dispatch, + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('receiveDeploymentsDataFailure'); + done(); + }) + .catch(done.fail); + }); + }); + describe('fetchEnvironmentsData', () => { + it('commits RECEIVE_ENVIRONMENTS_DATA_SUCCESS on error', done => { + const dispatch = jest.fn(); + const { state } = store; + state.environmentsEndpoint = '/success'; + mock.onGet(state.environmentsEndpoint).reply(200, { + environments: environmentData, + }); + fetchEnvironmentsData({ + state, + dispatch, + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataSuccess', environmentData); + done(); + }) + .catch(done.fail); + }); + it('commits RECEIVE_ENVIRONMENTS_DATA_FAILURE on error', done => { + const dispatch = jest.fn(); + const { state } = store; + state.environmentsEndpoint = '/error'; + mock.onGet(state.environmentsEndpoint).reply(500); + fetchEnvironmentsData({ + state, + dispatch, + }) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('receiveEnvironmentsDataFailure'); + done(); + }) + .catch(done.fail); + }); + }); + describe('Set endpoints', () => { + let mockedState; + beforeEach(() => { + mockedState = storeState(); + }); + it('should commit SET_ENDPOINTS mutation', done => { + testAction( + setEndpoints, + { + metricsEndpoint: 'additional_metrics.json', + deploymentsEndpoint: 'deployments.json', + environmentsEndpoint: 'deployments.json', + }, + mockedState, + [ + { + type: types.SET_ENDPOINTS, + payload: { + metricsEndpoint: 'additional_metrics.json', + deploymentsEndpoint: 'deployments.json', + environmentsEndpoint: 'deployments.json', + }, + }, + ], + [], + done, + ); + }); + }); + describe('Set empty states', () => { + let mockedState; + beforeEach(() => { + mockedState = storeState(); + }); + it('should commit SET_METRICS_ENDPOINT mutation', done => { + testAction( + setGettingStartedEmptyState, + null, + mockedState, + [ + { + type: types.SET_GETTING_STARTED_EMPTY_STATE, + }, + ], + [], + done, + ); + }); + }); + describe('fetchDashboard', () => { + let dispatch; + let state; + const response = metricsDashboardResponse; + beforeEach(() => { + dispatch = jest.fn(); + state = storeState(); + state.dashboardEndpoint = '/dashboard'; + }); + it('dispatches receive and success actions', done => { + const params = {}; + mock.onGet(state.dashboardEndpoint).reply(200, response); + fetchDashboard( + { + state, + dispatch, + }, + params, + ) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('requestMetricsDashboard'); + expect(dispatch).toHaveBeenCalledWith('receiveMetricsDashboardSuccess', { + response, + params, + }); + done(); + }) + .catch(done.fail); + }); + it('dispatches failure action', done => { + const params = {}; + mock.onGet(state.dashboardEndpoint).reply(500); + fetchDashboard( + { + state, + dispatch, + }, + params, + ) + .then(() => { + expect(dispatch).toHaveBeenCalledWith( + 'receiveMetricsDashboardFailure', + new Error('Request failed with status code 500'), + ); + done(); + }) + .catch(done.fail); + }); + }); + describe('receiveMetricsDashboardSuccess', () => { + let commit; + let dispatch; + let state; + beforeEach(() => { + commit = jest.fn(); + dispatch = jest.fn(); + state = storeState(); + }); + it('stores groups ', () => { + const params = {}; + const response = metricsDashboardResponse; + receiveMetricsDashboardSuccess( + { + state, + commit, + dispatch, + }, + { + response, + params, + }, + ); + expect(commit).toHaveBeenCalledWith( + types.RECEIVE_METRICS_DATA_SUCCESS, + metricsDashboardResponse.dashboard.panel_groups, + ); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetrics', params); + }); + it('sets the dashboards loaded from the repository', () => { + const params = {}; + const response = metricsDashboardResponse; + response.all_dashboards = dashboardGitResponse; + receiveMetricsDashboardSuccess( + { + state, + commit, + dispatch, + }, + { + response, + params, + }, + ); + expect(commit).toHaveBeenCalledWith(types.SET_ALL_DASHBOARDS, dashboardGitResponse); + }); + }); + describe('receiveMetricsDashboardFailure', () => { + let commit; + beforeEach(() => { + commit = jest.fn(); + }); + it('commits failure action', () => { + receiveMetricsDashboardFailure({ + commit, + }); + expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, undefined); + }); + it('commits failure action with error', () => { + receiveMetricsDashboardFailure( + { + commit, + }, + 'uh-oh', + ); + expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, 'uh-oh'); + }); + }); + describe('fetchPrometheusMetrics', () => { + let commit; + let dispatch; + beforeEach(() => { + commit = jest.fn(); + dispatch = jest.fn(); + }); + it('commits empty state when state.groups is empty', done => { + const state = storeState(); + const params = {}; + fetchPrometheusMetrics( + { + state, + commit, + dispatch, + }, + params, + ) + .then(() => { + expect(commit).toHaveBeenCalledWith(types.SET_NO_DATA_EMPTY_STATE); + expect(dispatch).not.toHaveBeenCalled(); + done(); + }) + .catch(done.fail); + }); + it('dispatches fetchPrometheusMetric for each panel query', done => { + const params = {}; + const state = storeState(); + state.dashboard.panel_groups = metricsDashboardResponse.dashboard.panel_groups; + const metric = state.dashboard.panel_groups[0].panels[0].metrics[0]; + fetchPrometheusMetrics( + { + state, + commit, + dispatch, + }, + params, + ) + .then(() => { + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', { + metric, + params, + }); + done(); + }) + .catch(done.fail); + done(); + }); + }); + describe('fetchPrometheusMetric', () => { + it('commits prometheus query result', done => { + const commit = jest.fn(); + const params = { + start: '2019-08-06T12:40:02.184Z', + end: '2019-08-06T20:40:02.184Z', + }; + const metric = metricsDashboardResponse.dashboard.panel_groups[0].panels[0].metrics[0]; + const state = storeState(); + const data = metricsGroupsAPIResponse[0].panels[0].metrics[0]; + const response = { + data, + }; + mock.onGet('http://test').reply(200, response); + fetchPrometheusMetric({ state, commit }, { metric, params }) + .then(() => { + expect(commit).toHaveBeenCalledWith(types.SET_QUERY_RESULT, { + metricId: metric.metric_id, + result: data.result, + }); + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js new file mode 100644 index 00000000000..fdad290a8d6 --- /dev/null +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -0,0 +1,142 @@ +import mutations from '~/monitoring/stores/mutations'; +import * as types from '~/monitoring/stores/mutation_types'; +import state from '~/monitoring/stores/state'; +import { + metricsGroupsAPIResponse, + deploymentData, + metricsDashboardResponse, + dashboardGitResponse, +} from '../mock_data'; +import { uniqMetricsId } from '~/monitoring/stores/utils'; + +describe('Monitoring mutations', () => { + let stateCopy; + beforeEach(() => { + stateCopy = state(); + }); + describe('RECEIVE_METRICS_DATA_SUCCESS', () => { + let groups; + beforeEach(() => { + stateCopy.dashboard.panel_groups = []; + groups = metricsGroupsAPIResponse; + }); + it('adds a key to the group', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); + expect(stateCopy.dashboard.panel_groups[0].key).toBe('system-metrics-kubernetes--0'); + }); + it('normalizes values', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); + const expectedLabel = 'Pod average'; + const { label, query_range } = stateCopy.dashboard.panel_groups[0].metrics[0].metrics[0]; + expect(label).toEqual(expectedLabel); + expect(query_range.length).toBeGreaterThan(0); + }); + it('contains one group, which it has two panels and one metrics property', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); + expect(stateCopy.dashboard.panel_groups).toBeDefined(); + expect(stateCopy.dashboard.panel_groups.length).toEqual(1); + expect(stateCopy.dashboard.panel_groups[0].panels.length).toEqual(2); + expect(stateCopy.dashboard.panel_groups[0].panels[0].metrics.length).toEqual(1); + expect(stateCopy.dashboard.panel_groups[0].panels[1].metrics.length).toEqual(1); + }); + it('assigns queries a metric id', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups); + expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries[0].metricId).toEqual( + '17_system_metrics_kubernetes_container_memory_average', + ); + }); + describe('dashboard endpoint', () => { + const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; + it('aliases group panels to metrics for backwards compatibility', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); + expect(stateCopy.dashboard.panel_groups[0].metrics[0]).toBeDefined(); + }); + it('aliases panel metrics to queries for backwards compatibility', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); + expect(stateCopy.dashboard.panel_groups[0].metrics[0].queries).toBeDefined(); + }); + }); + }); + + describe('RECEIVE_DEPLOYMENTS_DATA_SUCCESS', () => { + it('stores the deployment data', () => { + stateCopy.deploymentData = []; + mutations[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](stateCopy, deploymentData); + expect(stateCopy.deploymentData).toBeDefined(); + expect(stateCopy.deploymentData.length).toEqual(3); + expect(typeof stateCopy.deploymentData[0]).toEqual('object'); + }); + }); + describe('SET_ENDPOINTS', () => { + it('should set all the endpoints', () => { + mutations[types.SET_ENDPOINTS](stateCopy, { + metricsEndpoint: 'additional_metrics.json', + environmentsEndpoint: 'environments.json', + deploymentsEndpoint: 'deployments.json', + dashboardEndpoint: 'dashboard.json', + projectPath: '/gitlab-org/gitlab-foss', + }); + expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json'); + expect(stateCopy.environmentsEndpoint).toEqual('environments.json'); + expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json'); + expect(stateCopy.dashboardEndpoint).toEqual('dashboard.json'); + expect(stateCopy.projectPath).toEqual('/gitlab-org/gitlab-foss'); + }); + }); + describe('SET_QUERY_RESULT', () => { + const metricId = 12; + const id = 'system_metrics_kubernetes_container_memory_total'; + const result = [ + { + values: [[0, 1], [1, 1], [1, 3]], + }, + ]; + beforeEach(() => { + const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups; + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); + }); + it('clears empty state', () => { + mutations[types.SET_QUERY_RESULT](stateCopy, { + metricId, + result, + }); + expect(stateCopy.showEmptyState).toBe(false); + }); + it('sets metricsWithData value', () => { + const uniqId = uniqMetricsId({ + metric_id: metricId, + id, + }); + mutations[types.SET_QUERY_RESULT](stateCopy, { + metricId: uniqId, + result, + }); + expect(stateCopy.metricsWithData).toEqual([uniqId]); + }); + it('does not store empty results', () => { + mutations[types.SET_QUERY_RESULT](stateCopy, { + metricId, + result: [], + }); + expect(stateCopy.metricsWithData).toEqual([]); + }); + }); + describe('SET_ALL_DASHBOARDS', () => { + it('stores `undefined` dashboards as an empty array', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, undefined); + + expect(stateCopy.allDashboards).toEqual([]); + }); + + it('stores `null` dashboards as an empty array', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, null); + + expect(stateCopy.allDashboards).toEqual([]); + }); + + it('stores dashboards loaded from the git repository', () => { + mutations[types.SET_ALL_DASHBOARDS](stateCopy, dashboardGitResponse); + expect(stateCopy.allDashboards).toEqual(dashboardGitResponse); + }); + }); +}); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js new file mode 100644 index 00000000000..98388ac19f8 --- /dev/null +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -0,0 +1,74 @@ +import { groupQueriesByChartInfo, normalizeMetric, uniqMetricsId } from '~/monitoring/stores/utils'; + +describe('groupQueriesByChartInfo', () => { + let input; + let output; + + it('groups metrics with the same chart title and y_axis label', () => { + input = [ + { title: 'title', y_label: 'MB', queries: [{}] }, + { title: 'title', y_label: 'MB', queries: [{}] }, + { title: 'new title', y_label: 'MB', queries: [{}] }, + ]; + + output = [ + { + title: 'title', + y_label: 'MB', + queries: [{ metricId: null }, { metricId: null }], + }, + { title: 'new title', y_label: 'MB', queries: [{ metricId: null }] }, + ]; + + expect(groupQueriesByChartInfo(input)).toEqual(output); + }); + + // Functionality associated with the /additional_metrics endpoint + it("associates a chart's stringified metric_id with the metric", () => { + input = [{ id: 3, title: 'new title', y_label: 'MB', queries: [{}] }]; + output = [{ id: 3, title: 'new title', y_label: 'MB', queries: [{ metricId: '3' }] }]; + + expect(groupQueriesByChartInfo(input)).toEqual(output); + }); + + // Functionality associated with the /metrics_dashboard endpoint + it('aliases a stringified metrics_id on the metric to the metricId key', () => { + input = [{ title: 'new title', y_label: 'MB', queries: [{ metric_id: 3 }] }]; + output = [{ title: 'new title', y_label: 'MB', queries: [{ metricId: '3', metric_id: 3 }] }]; + + expect(groupQueriesByChartInfo(input)).toEqual(output); + }); +}); + +describe('normalizeMetric', () => { + [ + { args: [], expected: 'undefined_undefined' }, + { args: [undefined], expected: 'undefined_undefined' }, + { args: [{ id: 'something' }], expected: 'undefined_something' }, + { args: [{ id: 45 }], expected: 'undefined_45' }, + { args: [{ metric_id: 5 }], expected: '5_undefined' }, + { args: [{ metric_id: 'something' }], expected: 'something_undefined' }, + { + args: [{ metric_id: 5, id: 'system_metrics_kubernetes_container_memory_total' }], + expected: '5_system_metrics_kubernetes_container_memory_total', + }, + ].forEach(({ args, expected }) => { + it(`normalizes metric to "${expected}" with args=${JSON.stringify(args)}`, () => { + expect(normalizeMetric(...args)).toEqual({ metric_id: expected }); + }); + }); +}); + +describe('uniqMetricsId', () => { + [ + { input: { id: 1 }, expected: 'undefined_1' }, + { input: { metric_id: 2 }, expected: '2_undefined' }, + { input: { metric_id: 2, id: 21 }, expected: '2_21' }, + { input: { metric_id: 22, id: 1 }, expected: '22_1' }, + { input: { metric_id: 'aaa', id: '_a' }, expected: 'aaa__a' }, + ].forEach(({ input, expected }) => { + it(`creates unique metric ID with ${JSON.stringify(input)}`, () => { + expect(uniqMetricsId(input)).toEqual(expected); + }); + }); +}); diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js new file mode 100644 index 00000000000..45b99b71e06 --- /dev/null +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -0,0 +1,331 @@ +import $ from 'jquery'; +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import Autosize from 'autosize'; +import axios from '~/lib/utils/axios_utils'; +import createStore from '~/notes/stores'; +import CommentForm from '~/notes/components/comment_form.vue'; +import * as constants from '~/notes/constants'; +import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import { trimText } from 'helpers/text_helper'; +import { keyboardDownEvent } from '../../issue_show/helpers'; +import { + loggedOutnoteableData, + notesDataMock, + userDataMock, + noteableDataMock, +} from '../../notes/mock_data'; + +jest.mock('autosize'); +jest.mock('~/commons/nav/user_merge_requests'); +jest.mock('~/gl_form'); + +describe('issue_comment_form component', () => { + let store; + let wrapper; + let axiosMock; + + const setupStore = (userData, noteableData) => { + store.dispatch('setUserData', userData); + store.dispatch('setNoteableData', noteableData); + store.dispatch('setNotesData', notesDataMock); + }; + + const mountComponent = (noteableType = 'issue') => { + wrapper = mount(CommentForm, { + propsData: { + noteableType, + }, + store, + sync: false, + }); + }; + + beforeEach(() => { + axiosMock = new MockAdapter(axios); + store = createStore(); + }); + + afterEach(() => { + axiosMock.restore(); + wrapper.destroy(); + jest.clearAllMocks(); + }); + + describe('user is logged in', () => { + beforeEach(() => { + setupStore(userDataMock, noteableDataMock); + + mountComponent(); + }); + + it('should render user avatar with link', () => { + expect(wrapper.find('.timeline-icon .user-avatar-link').attributes('href')).toEqual( + userDataMock.path, + ); + }); + + describe('handleSave', () => { + it('should request to save note when note is entered', () => { + wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + jest.spyOn(wrapper.vm, 'resizeTextarea'); + jest.spyOn(wrapper.vm, 'stopPolling'); + + wrapper.vm.handleSave(); + + expect(wrapper.vm.isSubmitting).toEqual(true); + expect(wrapper.vm.note).toEqual(''); + expect(wrapper.vm.saveNote).toHaveBeenCalled(); + expect(wrapper.vm.stopPolling).toHaveBeenCalled(); + expect(wrapper.vm.resizeTextarea).toHaveBeenCalled(); + }); + + it('should toggle issue state when no note', () => { + jest.spyOn(wrapper.vm, 'toggleIssueState'); + + wrapper.vm.handleSave(); + + expect(wrapper.vm.toggleIssueState).toHaveBeenCalled(); + }); + + it('should disable action button whilst submitting', done => { + const saveNotePromise = Promise.resolve(); + wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(saveNotePromise); + jest.spyOn(wrapper.vm, 'stopPolling'); + + const actionButton = wrapper.find('.js-action-button'); + + wrapper.vm.handleSave(); + + wrapper.vm + .$nextTick() + .then(() => { + expect(actionButton.vm.disabled).toBeTruthy(); + }) + .then(saveNotePromise) + .then(wrapper.vm.$nextTick) + .then(() => { + expect(actionButton.vm.disabled).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('textarea', () => { + it('should render textarea with placeholder', () => { + expect(wrapper.find('.js-main-target-form textarea').attributes('placeholder')).toEqual( + 'Write a comment or drag your files here…', + ); + }); + + it('should make textarea disabled while requesting', done => { + const $submitButton = $(wrapper.find('.js-comment-submit-button').element); + wrapper.vm.note = 'hello world'; + jest.spyOn(wrapper.vm, 'stopPolling'); + jest.spyOn(wrapper.vm, 'saveNote').mockReturnValue(new Promise(() => {})); + + wrapper.vm.$nextTick(() => { + // Wait for wrapper.vm.note change triggered. It should enable $submitButton. + $submitButton.trigger('click'); + + wrapper.vm.$nextTick(() => { + // Wait for wrapper.isSubmitting triggered. It should disable textarea. + expect(wrapper.find('.js-main-target-form textarea').attributes('disabled')).toBe( + 'disabled', + ); + done(); + }); + }); + }); + + it('should support quick actions', () => { + expect( + wrapper.find('.js-main-target-form textarea').attributes('data-supports-quick-actions'), + ).toBe('true'); + }); + + it('should link to markdown docs', () => { + const { markdownDocsPath } = notesDataMock; + + expect( + wrapper + .find(`a[href="${markdownDocsPath}"]`) + .text() + .trim(), + ).toEqual('Markdown'); + }); + + it('should link to quick actions docs', () => { + const { quickActionsDocsPath } = notesDataMock; + + expect( + wrapper + .find(`a[href="${quickActionsDocsPath}"]`) + .text() + .trim(), + ).toEqual('quick actions'); + }); + + it('should resize textarea after note discarded', done => { + jest.spyOn(wrapper.vm, 'discard'); + + wrapper.vm.note = 'foo'; + wrapper.vm.discard(); + + wrapper.vm.$nextTick(() => { + expect(Autosize.update).toHaveBeenCalled(); + done(); + }); + }); + + describe('edit mode', () => { + it('should enter edit mode when arrow up is pressed', () => { + jest.spyOn(wrapper.vm, 'editCurrentUserLastNote'); + wrapper.find('.js-main-target-form textarea').value = 'Foo'; + wrapper + .find('.js-main-target-form textarea') + .element.dispatchEvent(keyboardDownEvent(38, true)); + + expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled(); + }); + + it('inits autosave', () => { + expect(wrapper.vm.autosave).toBeDefined(); + expect(wrapper.vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); + }); + }); + + describe('event enter', () => { + it('should save note when cmd+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); + wrapper.find('.js-main-target-form textarea').value = 'Foo'; + wrapper + .find('.js-main-target-form textarea') + .element.dispatchEvent(keyboardDownEvent(13, true)); + + expect(wrapper.vm.handleSave).toHaveBeenCalled(); + }); + + it('should save note when ctrl+enter is pressed', () => { + jest.spyOn(wrapper.vm, 'handleSave'); + wrapper.find('.js-main-target-form textarea').value = 'Foo'; + wrapper + .find('.js-main-target-form textarea') + .element.dispatchEvent(keyboardDownEvent(13, false, true)); + + expect(wrapper.vm.handleSave).toHaveBeenCalled(); + }); + }); + }); + + describe('actions', () => { + it('should be possible to close the issue', () => { + expect( + wrapper + .find('.btn-comment-and-close') + .text() + .trim(), + ).toEqual('Close issue'); + }); + + it('should render comment button as disabled', () => { + expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toEqual( + 'disabled', + ); + }); + + it('should enable comment button if it has note', done => { + wrapper.vm.note = 'Foo'; + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-comment-submit-button').attributes('disabled')).toBeFalsy(); + done(); + }); + }); + + it('should update buttons texts when it has note', done => { + wrapper.vm.note = 'Foo'; + wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('.btn-comment-and-close') + .text() + .trim(), + ).toEqual('Comment & close issue'); + + done(); + }); + }); + + it('updates button text with noteable type', done => { + wrapper.setProps({ noteableType: constants.MERGE_REQUEST_NOTEABLE_TYPE }); + + wrapper.vm.$nextTick(() => { + expect( + wrapper + .find('.btn-comment-and-close') + .text() + .trim(), + ).toEqual('Close merge request'); + done(); + }); + }); + + describe('when clicking close/reopen button', () => { + it('should disable button and show a loading spinner', done => { + const toggleStateButton = wrapper.find('.js-action-button'); + + toggleStateButton.trigger('click'); + wrapper.vm.$nextTick(() => { + expect(toggleStateButton.element.disabled).toEqual(true); + expect(toggleStateButton.find('.js-loading-button-icon').exists()).toBe(true); + + done(); + }); + }); + }); + + describe('when toggling state', () => { + it('should update MR count', done => { + jest.spyOn(wrapper.vm, 'closeIssue').mockResolvedValue(); + + wrapper.vm.toggleIssueState(); + + wrapper.vm.$nextTick(() => { + expect(refreshUserMergeRequestCounts).toHaveBeenCalled(); + + done(); + }); + }); + }); + }); + + describe('issue is confidential', () => { + it('shows information warning', done => { + store.dispatch('setNoteableData', Object.assign(noteableDataMock, { confidential: true })); + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.confidential-issue-warning')).toBeDefined(); + done(); + }); + }); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + setupStore(null, loggedOutnoteableData); + + mountComponent(); + }); + + it('should render signed out widget', () => { + expect(trimText(wrapper.text())).toEqual('Please register or sign in to reply'); + }); + + it('should not render submission form', () => { + expect(wrapper.find('textarea').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js new file mode 100644 index 00000000000..f90147f9105 --- /dev/null +++ b/spec/frontend/notes/components/diff_discussion_header_spec.js @@ -0,0 +1,141 @@ +import { mount, createLocalVue } from '@vue/test-utils'; + +import createStore from '~/notes/stores'; +import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; + +import { discussionMock } from '../../../javascripts/notes/mock_data'; +import mockDiffFile from '../../diffs/mock_data/diff_discussions'; + +const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; + +describe('diff_discussion_header component', () => { + let store; + let wrapper; + + preloadFixtures(discussionWithTwoUnresolvedNotes); + + beforeEach(() => { + window.mrTabs = {}; + store = createStore(); + + const localVue = createLocalVue(); + wrapper = mount(diffDiscussionHeader, { + store, + propsData: { discussion: discussionMock }, + localVue, + sync: false, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render user avatar', () => { + const discussion = { ...discussionMock }; + discussion.diff_file = mockDiffFile; + discussion.diff_discussion = true; + + wrapper.setProps({ discussion }); + + expect(wrapper.find('.user-avatar-link').exists()).toBe(true); + }); + + describe('action text', () => { + const commitId = 'razupaltuff'; + const truncatedCommitId = commitId.substr(0, 8); + let commitElement; + + beforeEach(done => { + store.state.diffs = { + projectPath: 'something', + }; + + wrapper.setProps({ + discussion: { + ...discussionMock, + for_commit: true, + commit_id: commitId, + diff_discussion: true, + diff_file: { + ...mockDiffFile, + }, + }, + }); + + wrapper.vm + .$nextTick() + .then(() => { + commitElement = wrapper.find('.commit-sha'); + }) + .then(done) + .catch(done.fail); + }); + + describe('for diff threads without a commit id', () => { + it('should show started a thread on the diff text', done => { + Object.assign(wrapper.vm.discussion, { + for_commit: false, + commit_id: null, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain('started a thread on the diff'); + + done(); + }); + }); + + it('should show thread on older version text', done => { + Object.assign(wrapper.vm.discussion, { + for_commit: false, + commit_id: null, + active: false, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain('started a thread on an old version of the diff'); + + done(); + }); + }); + }); + + describe('for commit threads', () => { + it('should display a monospace started a thread on commit', () => { + expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); + expect(commitElement.exists()).toBe(true); + expect(commitElement.text()).toContain(truncatedCommitId); + }); + }); + + describe('for diff thread with a commit id', () => { + it('should display started thread on commit header', done => { + wrapper.vm.discussion.for_commit = false; + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain(`started a thread on commit ${truncatedCommitId}`); + + expect(commitElement).not.toBe(null); + + done(); + }); + }); + + it('should display outdated change on commit header', done => { + wrapper.vm.discussion.for_commit = false; + wrapper.vm.discussion.active = false; + + wrapper.vm.$nextTick(() => { + expect(wrapper.text()).toContain( + `started a thread on an outdated change in commit ${truncatedCommitId}`, + ); + + expect(commitElement).not.toBe(null); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index d3c8cf72376..91f9dab2530 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -1,6 +1,6 @@ import createStore from '~/notes/stores'; import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; -import { discussionMock } from '../../../javascripts/notes/mock_data'; +import { discussionMock } from '../../notes/mock_data'; import DiscussionActions from '~/notes/components/discussion_actions.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ResolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue'; diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index 58d367077e8..f77236b14bc 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -8,11 +8,7 @@ import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_sys import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import createStore from '~/notes/stores'; -import { - noteableDataMock, - discussionMock, - notesDataMock, -} from '../../../javascripts/notes/mock_data'; +import { noteableDataMock, discussionMock, notesDataMock } from '../../notes/mock_data'; const localVue = createLocalVue(); diff --git a/spec/frontend/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js index a8ec47fd44f..3716b349210 100644 --- a/spec/frontend/notes/components/note_app_spec.js +++ b/spec/frontend/notes/components/note_app_spec.js @@ -9,7 +9,8 @@ import createStore from '~/notes/stores'; import '~/behaviors/markdown/render_gfm'; import { setTestTimeout } from 'helpers/timeout'; // TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491) -import * as mockData from '../../../javascripts/notes/mock_data'; +import * as mockData from '../../notes/mock_data'; +import * as urlUtility from '~/lib/utils/url_utility'; setTestTimeout(1000); @@ -54,7 +55,9 @@ describe('note_app', () => { components: { NotesApp, }, - template: '<div class="js-vue-notes-event"><notes-app v-bind="$attrs" /></div>', + template: `<div class="js-vue-notes-event"> + <notes-app ref="notesApp" v-bind="$attrs" /> + </div>`, }, { attachToDocument: true, @@ -313,4 +316,23 @@ describe('note_app', () => { }); }); }); + + describe('mounted', () => { + beforeEach(() => { + axiosMock.onAny().reply(mockData.getIndividualNoteResponse); + wrapper = mountComponent(); + return waitForDiscussionsRequest(); + }); + + it('should listen hashchange event', () => { + const notesApp = wrapper.find(NotesApp); + const hash = 'some dummy hash'; + jest.spyOn(urlUtility, 'getLocationHash').mockReturnValueOnce(hash); + const setTargetNoteHash = jest.spyOn(notesApp.vm, 'setTargetNoteHash'); + + window.dispatchEvent(new Event('hashchange'), hash); + + expect(setTargetNoteHash).toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js new file mode 100644 index 00000000000..01cb70d395c --- /dev/null +++ b/spec/frontend/notes/mock_data.js @@ -0,0 +1,1255 @@ +// Copied to ee/spec/frontend/notes/mock_data.js + +export const notesDataMock = { + discussionsPath: '/gitlab-org/gitlab-foss/issues/26/discussions.json', + lastFetchedAt: 1501862675, + markdownDocsPath: '/help/user/markdown', + newSessionPath: '/users/sign_in?redirect_to_referer=yes', + notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes', + quickActionsDocsPath: '/help/user/project/quick_actions', + registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', + prerenderedNotesCount: 1, + closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', + reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', + canAwardEmoji: true, +}; + +export const userDataMock = { + avatar_url: 'mock_path', + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', +}; + +export const noteableDataMock = { + assignees: [], + author_id: 1, + branch_name: null, + confidential: false, + create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue', + created_at: '2017-02-07T10:11:18.395Z', + current_user: { + can_create_note: true, + can_update: true, + can_award_emoji: true, + }, + description: '', + due_date: null, + human_time_estimate: null, + human_total_time_spent: null, + id: 98, + iid: 26, + labels: [], + lock_version: null, + milestone: null, + milestone_id: null, + moved_to_id: null, + preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue', + project_id: 2, + state: 'opened', + time_estimate: 0, + title: '14', + total_time_spent: 0, + noteable_note_url: '/group/project/merge_requests/1#note_1', + updated_at: '2017-08-04T09:53:01.226Z', + updated_by_id: 1, + web_url: '/gitlab-org/gitlab-foss/issues/26', + noteableType: 'issue', +}; + +export const lastFetchedAt = '1501862675'; + +export const individualNote = { + expanded: true, + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + individual_note: true, + notes: [ + { + id: '1390', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2017-08-01T17: 09: 33.762Z', + updated_at: '2017-08-01T17: 09: 33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: "<p dir='auto'>sdfdsaf</p>", + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } }, + { name: 'art', user: { id: 1, name: 'Root', username: 'root' } }, + ], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji', + noteable_note_url: '/group/project/merge_requests/1#note_1', + note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1390', + }, + ], + reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', +}; + +export const note = { + id: '546', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2017-08-10T15:24:03.087Z', + updated_at: '2017-08-10T15:24:03.087Z', + system: false, + noteable_id: 67, + noteable_type: 'Issue', + noteable_iid: 7, + type: null, + human_access: 'Owner', + note: 'Vel id placeat reprehenderit sit numquam.', + note_html: '<p dir="auto">Vel id placeat reprehenderit sit numquam.</p>', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: 'd3842a451b7f3d9a5dfce329515127b2d29a4cd0', + emoji_awardable: true, + award_emoji: [ + { + name: 'baseball', + user: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }, + { + name: 'bath_tone3', + user: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }, + ], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/546/toggle_award_emoji', + note_url: '/group/project/merge_requests/1#note_1', + noteable_note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/546', +}; + +export const discussionMock = { + id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + expanded: true, + notes: [ + { + id: '1395', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:58.559Z', + updated_at: '2017-08-02T10:51:58.559Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'THIS IS A DICUSSSION!', + note_html: "<p dir='auto'>THIS IS A DICUSSSION!</p>", + current_user: { + can_edit: true, + can_award_emoji: true, + can_resolve: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1395/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1395', + }, + { + id: '1396', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:56:50.980Z', + updated_at: '2017-08-03T14:19:35.691Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'sadfasdsdgdsf', + note_html: "<p dir='auto'>sadfasdsdgdsf</p>", + last_edited_at: '2017-08-03T14:19:35.691Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + can_resolve: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1396/toggle_award_emoji', + noteable_note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1396', + }, + { + id: '1437', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-03T18:11:18.780Z', + updated_at: '2017-08-04T09:52:31.062Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'adsfasf Should disappear', + note_html: "<p dir='auto'>adsfasf Should disappear</p>", + last_edited_at: '2017-08-04T09:52:31.062Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + can_resolve: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1437/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1437', + }, + ], + individual_note: false, + resolvable: true, + active: true, +}; + +export const loggedOutnoteableData = { + id: '98', + iid: 26, + author_id: 1, + description: '', + lock_version: 1, + milestone_id: null, + state: 'opened', + title: 'asdsa', + updated_by_id: 1, + created_at: '2017-02-07T10:11:18.395Z', + updated_at: '2017-08-08T10:22:51.564Z', + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + milestone: null, + labels: [], + branch_name: null, + confidential: false, + assignees: [ + { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + web_url: 'http://localhost:3000/root', + }, + ], + due_date: null, + moved_to_id: null, + project_id: 2, + web_url: '/gitlab-org/gitlab-foss/issues/26', + current_user: { + can_create_note: false, + can_update: false, + }, + noteable_note_url: '/group/project/merge_requests/1#note_1', + create_note_path: '/gitlab-org/gitlab-foss/notes?target_id=98&target_type=issue', + preview_note_path: '/gitlab-org/gitlab-foss/preview_markdown?target_id=98&target_type=Issue', +}; + +export const collapseNotesMock = [ + { + expanded: true, + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + individual_note: true, + notes: [ + { + id: '1390', + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2018-02-26T18:07:41.071Z', + updated_at: '2018-02-26T18:07:41.071Z', + system: true, + system_note_icon_name: 'pencil', + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false }, + discussion_id: 'b97fb7bda470a65b3e009377a9032edec0a4dd05', + emoji_awardable: false, + path: '/h5bp/html5-boilerplate/notes/1057', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1', + }, + ], + }, + { + expanded: true, + id: 'ffde43f25984ad7f2b4275135e0e2846875336c0', + individual_note: true, + notes: [ + { + id: '1391', + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2018-02-26T18:13:24.071Z', + updated_at: '2018-02-26T18:13:24.071Z', + system: true, + system_note_icon_name: 'pencil', + noteable_id: 99, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false }, + discussion_id: '3eb958b4d81dec207ec3537a2f3bd8b9f271bb34', + emoji_awardable: false, + path: '/h5bp/html5-boilerplate/notes/1057', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fh5bp%2Fhtml5-boilerplate%2Fissues%2F10%23note_1057&user_id=1', + }, + ], + }, +]; + +export const INDIVIDUAL_NOTE_RESPONSE_MAP = { + GET: { + '/gitlab-org/gitlab-foss/issues/26/discussions.json': [ + { + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + expanded: true, + notes: [ + { + id: '1390', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-01T17:09:33.762Z', + updated_at: '2017-08-01T17:09:33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: '\u003cp dir="auto"\u003esdfdsaf\u003c/p\u003e', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { + name: 'baseball', + user: { + id: 1, + name: 'Root', + username: 'root', + }, + }, + { + name: 'art', + user: { + id: 1, + name: 'Root', + username: 'root', + }, + }, + ], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1390/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1390', + }, + ], + individual_note: true, + }, + { + id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + reply_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + expanded: true, + notes: [ + { + id: '1391', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:38.685Z', + updated_at: '2017-08-02T10:51:38.685Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'New note!', + note_html: '\u003cp dir="auto"\u003eNew note!\u003c/p\u003e', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: '70d5c92a4039a36c70100c6691c18c27e4b0a790', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1391/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1391', + }, + ], + individual_note: true, + }, + ], + '/gitlab-org/gitlab-foss/noteable/issue/98/notes': { + last_fetched_at: 1512900838, + notes: [], + }, + }, + PUT: { + '/gitlab-org/gitlab-foss/notes/1471': { + commands_changes: null, + valid: true, + id: '1471', + attachment: null, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-08T16:53:00.666Z', + updated_at: '2017-12-10T11:03:21.876Z', + system: false, + noteable_id: 124, + noteable_type: 'Issue', + noteable_iid: 29, + type: 'DiscussionNote', + human_access: 'Owner', + note: 'Adding a comment', + note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', + last_edited_at: '2017-12-10T11:03:21.876Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + emoji_awardable: true, + award_emoji: [], + noteable_note_url: '/group/project/merge_requests/1#note_1', + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1471', + }, + }, +}; + +export const DISCUSSION_NOTE_RESPONSE_MAP = { + ...INDIVIDUAL_NOTE_RESPONSE_MAP, + GET: { + ...INDIVIDUAL_NOTE_RESPONSE_MAP.GET, + '/gitlab-org/gitlab-foss/issues/26/discussions.json': [ + { + id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + reply_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + expanded: true, + notes: [ + { + id: '1471', + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-08T16:53:00.666Z', + updated_at: '2017-08-08T16:53:00.666Z', + system: false, + noteable_id: 124, + noteable_type: 'Issue', + noteable_iid: 29, + type: 'DiscussionNote', + human_access: 'Owner', + note: 'Adding a comment', + note_html: '\u003cp dir="auto"\u003eAdding a comment\u003c/p\u003e', + current_user: { + can_edit: true, + can_award_emoji: true, + }, + discussion_id: 'a3ed36e29b1957efb3b68c53e2d7a2b24b1df052', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-foss/notes/1471/toggle_award_emoji', + noteable_note_url: '/group/project/merge_requests/1#note_1', + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1', + path: '/gitlab-org/gitlab-foss/notes/1471', + }, + ], + individual_note: false, + }, + ], + }, +}; + +export function getIndividualNoteResponse(config) { + return [200, INDIVIDUAL_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]]; +} + +export function getDiscussionNoteResponse(config) { + return [200, DISCUSSION_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]]; +} + +export const notesWithDescriptionChanges = [ + { + id: '39b271c2033e9ed43d8edb393702f65f7a830459', + reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + expanded: true, + notes: [ + { + id: '901', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:36.117Z', + updated_at: '2018-05-29T12:05:36.117Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + note_html: + '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/901', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + expanded: true, + notes: [ + { + id: '902', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:58.694Z', + updated_at: '2018-05-29T12:05:58.694Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.', + note_html: + '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/902', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '7f1feda384083eb31763366e6392399fde6f3f31', + reply_id: '7f1feda384083eb31763366e6392399fde6f3f31', + expanded: true, + notes: [ + { + id: '903', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:05.772Z', + updated_at: '2018-05-29T12:06:05.772Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: '7f1feda384083eb31763366e6392399fde6f3f31', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_903&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/903', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + expanded: true, + notes: [ + { + id: '904', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:16.112Z', + updated_at: '2018-05-29T12:06:16.112Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'Ullamcorper eget nulla facilisi etiam', + note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/904', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + expanded: true, + notes: [ + { + id: '905', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:28.851Z', + updated_at: '2018-05-29T12:06:28.851Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/905', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '70411b08cdfc01f24187a06d77daa33464cb2620', + reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + expanded: true, + notes: [ + { + id: '906', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:20:02.925Z', + updated_at: '2018-05-29T12:20:02.925Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/906', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, +]; + +export const collapsedSystemNotes = [ + { + id: '39b271c2033e9ed43d8edb393702f65f7a830459', + reply_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + expanded: true, + notes: [ + { + id: '901', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:36.117Z', + updated_at: '2018-05-29T12:05:36.117Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + note_html: + '<p dir="auto">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '39b271c2033e9ed43d8edb393702f65f7a830459', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_901&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/901/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/901', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + reply_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + expanded: true, + notes: [ + { + id: '902', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:05:58.694Z', + updated_at: '2018-05-29T12:05:58.694Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: + 'Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.', + note_html: + '<p dir="auto">Varius vel pharetra vel turpis nunc eget lorem. Ipsum dolor sit amet consectetur adipiscing.</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '4852335d7dc40b9ceb8fde1a2bb9c1b67e4c7795', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_902&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/902/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/902', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + reply_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + expanded: true, + notes: [ + { + id: '904', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:16.112Z', + updated_at: '2018-05-29T12:06:16.112Z', + system: false, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'Ullamcorper eget nulla facilisi etiam', + note_html: '<p dir="auto">Ullamcorper eget nulla facilisi etiam</p>', + current_user: { can_edit: true, can_award_emoji: true }, + resolved: false, + resolved_by: null, + discussion_id: '091865fe3ae20f0045234a3d103e3b15e73405b5', + emoji_awardable: true, + award_emoji: [], + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_904&user_id=1', + human_access: 'Owner', + toggle_award_path: '/gitlab-org/gitlab-shell/notes/904/toggle_award_emoji', + path: '/gitlab-org/gitlab-shell/notes/904', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + reply_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + expanded: true, + notes: [ + { + id: '905', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:06:28.851Z', + updated_at: '2018-05-29T12:06:28.851Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + start_description_version_id: undefined, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: 'a21cf2e804acc3c60d07e37d75e395f5a9a4d044', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_905&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/905', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, + { + id: '70411b08cdfc01f24187a06d77daa33464cb2620', + reply_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + expanded: true, + notes: [ + { + id: '906', + type: null, + attachment: null, + author: { + id: 1, + name: 'Administrator', + username: 'root', + state: 'active', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + path: '/root', + }, + created_at: '2018-05-29T12:20:02.925Z', + updated_at: '2018-05-29T12:20:02.925Z', + system: true, + noteable_id: 182, + noteable_type: 'Issue', + resolvable: false, + noteable_iid: 12, + note: 'changed the description', + note_html: '<p dir="auto">changed the description</p>', + current_user: { can_edit: false, can_award_emoji: true }, + resolved: false, + resolved_by: null, + system_note_icon_name: 'pencil-square', + discussion_id: '70411b08cdfc01f24187a06d77daa33464cb2620', + emoji_awardable: false, + report_abuse_path: + '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-shell%2Fissues%2F12%23note_906&user_id=1', + human_access: 'Owner', + path: '/gitlab-org/gitlab-shell/notes/906', + }, + ], + individual_note: true, + resolvable: false, + resolved: false, + diff_discussion: false, + }, +]; + +export const discussion1 = { + id: 'abc1', + resolvable: true, + resolved: false, + active: true, + diff_file: { + file_path: 'about.md', + }, + position: { + new_line: 50, + old_line: null, + }, + notes: [ + { + created_at: '2018-07-04T16:25:41.749Z', + }, + ], +}; + +export const resolvedDiscussion1 = { + id: 'abc1', + resolvable: true, + resolved: true, + diff_file: { + file_path: 'about.md', + }, + position: { + new_line: 50, + old_line: null, + }, + notes: [ + { + created_at: '2018-07-04T16:25:41.749Z', + }, + ], +}; + +export const discussion2 = { + id: 'abc2', + resolvable: true, + resolved: false, + active: true, + diff_file: { + file_path: 'README.md', + }, + position: { + new_line: null, + old_line: 20, + }, + notes: [ + { + created_at: '2018-07-04T12:05:41.749Z', + }, + ], +}; + +export const discussion3 = { + id: 'abc3', + resolvable: true, + active: true, + resolved: false, + diff_file: { + file_path: 'README.md', + }, + position: { + new_line: 21, + old_line: null, + }, + notes: [ + { + created_at: '2018-07-05T17:25:41.749Z', + }, + ], +}; + +export const unresolvableDiscussion = { + resolvable: false, +}; + +export const discussionFiltersMock = [ + { + title: 'Show all activity', + value: 0, + }, + { + title: 'Show comments only', + value: 1, + }, + { + title: 'Show system notes only', + value: 2, + }, +]; diff --git a/spec/frontend/performance_bar/components/add_request_spec.js b/spec/frontend/performance_bar/components/add_request_spec.js new file mode 100644 index 00000000000..cef264f3915 --- /dev/null +++ b/spec/frontend/performance_bar/components/add_request_spec.js @@ -0,0 +1,62 @@ +import AddRequest from '~/performance_bar/components/add_request.vue'; +import { shallowMount } from '@vue/test-utils'; + +describe('add request form', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(AddRequest); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('hides the input on load', () => { + expect(wrapper.find('input').exists()).toBe(false); + }); + + describe('when clicking the button', () => { + beforeEach(() => { + wrapper.find('button').trigger('click'); + }); + + it('shows the form', () => { + expect(wrapper.find('input').exists()).toBe(true); + }); + + describe('when pressing escape', () => { + beforeEach(() => { + wrapper.find('input').trigger('keyup.esc'); + }); + + it('hides the input', () => { + expect(wrapper.find('input').exists()).toBe(false); + }); + }); + + describe('when submitting the form', () => { + beforeEach(() => { + wrapper.find('input').setValue('http://gitlab.example.com/users/root/calendar.json'); + wrapper.find('input').trigger('keyup.enter'); + }); + + it('emits an event to add the request', () => { + expect(wrapper.emitted()['add-request']).toBeTruthy(); + expect(wrapper.emitted()['add-request'][0]).toEqual([ + 'http://gitlab.example.com/users/root/calendar.json', + ]); + }); + + it('hides the input', () => { + expect(wrapper.find('input').exists()).toBe(false); + }); + + it('clears the value for next time', () => { + wrapper.find('button').trigger('click'); + + expect(wrapper.find('input').text()).toEqual(''); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js new file mode 100644 index 00000000000..38ffe98c79b --- /dev/null +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -0,0 +1,75 @@ +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import ActionComponent from '~/pipelines/components/graph/action_component.vue'; + +describe('pipeline graph action component', () => { + let wrapper; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onPost('foo.json').reply(200); + + wrapper = mount(ActionComponent, { + propsData: { + tooltipText: 'bar', + link: 'foo', + actionIcon: 'cancel', + }, + sync: false, + }); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + it('should render the provided title as a bootstrap tooltip', () => { + expect(wrapper.attributes('data-original-title')).toBe('bar'); + }); + + it('should update bootstrap tooltip when title changes', done => { + wrapper.setProps({ tooltipText: 'changed' }); + + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.attributes('data-original-title')).toBe('changed'); + }) + .then(done) + .catch(done.fail); + }); + + it('should render an svg', () => { + expect(wrapper.find('.ci-action-icon-wrapper')).toBeDefined(); + expect(wrapper.find('svg')).toBeDefined(); + }); + + describe('on click', () => { + it('emits `pipelineActionRequestComplete` after a successful request', done => { + jest.spyOn(wrapper.vm, '$emit'); + + wrapper.find('button').trigger('click'); + + waitForPromises() + .then(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete'); + done(); + }) + .catch(done.fail); + }); + + it('renders a loading icon while waiting for request', done => { + wrapper.find('button').trigger('click'); + + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-action-icon-loading').exists()).toBe(true); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js new file mode 100644 index 00000000000..45ac278dd38 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -0,0 +1,57 @@ +import { mount } from '@vue/test-utils'; +import pipelineTriggerer from '~/pipelines/components/pipeline_triggerer.vue'; + +describe('Pipelines Triggerer', () => { + let wrapper; + + const mockData = { + pipeline: { + user: { + name: 'foo', + avatar_url: '/avatar', + path: '/path', + }, + }, + }; + + const createComponent = () => { + wrapper = mount(pipelineTriggerer, { + propsData: mockData, + sync: false, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render a table cell', () => { + expect(wrapper.contains('.table-section')).toBe(true); + }); + + it('should render triggerer information when triggerer is provided', () => { + const link = wrapper.find('.js-pipeline-url-user'); + + expect(link.attributes('href')).toEqual(mockData.pipeline.user.path); + expect(link.find('.js-user-avatar-image-toolip').text()).toEqual(mockData.pipeline.user.name); + expect(link.find('img.avatar').attributes('src')).toEqual( + `${mockData.pipeline.user.avatar_url}?width=26`, + ); + }); + + it('should render "API" when no triggerer is provided', () => { + wrapper.setProps({ + pipeline: { + user: null, + }, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API'); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_table_row_spec.js b/spec/frontend/pipelines/pipelines_table_row_spec.js new file mode 100644 index 00000000000..1c785ec6ffe --- /dev/null +++ b/spec/frontend/pipelines/pipelines_table_row_spec.js @@ -0,0 +1,228 @@ +import { mount } from '@vue/test-utils'; +import PipelinesTableRowComponent from '~/pipelines/components/pipelines_table_row.vue'; +import eventHub from '~/pipelines/event_hub'; + +describe('Pipelines Table Row', () => { + const jsonFixtureName = 'pipelines/pipelines.json'; + + const createWrapper = pipeline => + mount(PipelinesTableRowComponent, { + propsData: { + pipeline, + autoDevopsHelpPath: 'foo', + viewType: 'root', + }, + sync: false, + }); + + let wrapper; + let pipeline; + let pipelineWithoutAuthor; + let pipelineWithoutCommit; + + preloadFixtures(jsonFixtureName); + + beforeEach(() => { + const { pipelines } = getJSONFixture(jsonFixtureName); + + pipeline = pipelines.find(p => p.user !== null && p.commit !== null); + pipelineWithoutAuthor = pipelines.find(p => p.user === null && p.commit !== null); + pipelineWithoutCommit = pipelines.find(p => p.user === null && p.commit === null); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('should render a table row', () => { + wrapper = createWrapper(pipeline); + + expect(wrapper.attributes('class')).toContain('gl-responsive-table-row'); + }); + + describe('status column', () => { + beforeEach(() => { + wrapper = createWrapper(pipeline); + }); + + it('should render a pipeline link', () => { + expect(wrapper.find('.table-section.commit-link a').attributes('href')).toEqual( + pipeline.path, + ); + }); + + it('should render status text', () => { + expect(wrapper.find('.table-section.commit-link a').text()).toContain( + pipeline.details.status.text, + ); + }); + }); + + describe('information column', () => { + beforeEach(() => { + wrapper = createWrapper(pipeline); + }); + + it('should render a pipeline link', () => { + expect(wrapper.find('.table-section:nth-child(2) a').attributes('href')).toEqual( + pipeline.path, + ); + }); + + it('should render pipeline ID', () => { + expect(wrapper.find('.table-section:nth-child(2) a > span').text()).toEqual( + `#${pipeline.id}`, + ); + }); + + describe('when a user is provided', () => { + it('should render user information', () => { + expect( + wrapper.find('.table-section:nth-child(3) .js-pipeline-url-user').attributes('href'), + ).toEqual(pipeline.user.path); + + expect( + wrapper + .find('.table-section:nth-child(3) .js-user-avatar-image-toolip') + .text() + .trim(), + ).toEqual(pipeline.user.name); + }); + }); + }); + + describe('commit column', () => { + it('should render link to commit', () => { + wrapper = createWrapper(pipeline); + + const commitLink = wrapper.find('.branch-commit .commit-sha'); + + expect(commitLink.attributes('href')).toEqual(pipeline.commit.commit_path); + }); + + const findElements = () => { + const commitTitleElement = wrapper.find('.branch-commit .commit-title'); + const commitAuthorElement = commitTitleElement.find('a.avatar-image-container'); + + if (!commitAuthorElement.exists()) { + return { + commitAuthorElement, + }; + } + + const commitAuthorLink = commitAuthorElement.attributes('href'); + const commitAuthorName = commitAuthorElement + .find('.js-user-avatar-image-toolip') + .text() + .trim(); + + return { + commitAuthorElement, + commitAuthorLink, + commitAuthorName, + }; + }; + + it('renders nothing without commit', () => { + expect(pipelineWithoutCommit.commit).toBe(null); + + wrapper = createWrapper(pipelineWithoutCommit); + const { commitAuthorElement } = findElements(); + + expect(commitAuthorElement.exists()).toBe(false); + }); + + it('renders commit author', () => { + wrapper = createWrapper(pipeline); + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(pipeline.commit.author.path); + expect(commitAuthorName).toEqual(pipeline.commit.author.username); + }); + + it('renders commit with unregistered author', () => { + expect(pipelineWithoutAuthor.commit.author).toBe(null); + + wrapper = createWrapper(pipelineWithoutAuthor); + const { commitAuthorLink, commitAuthorName } = findElements(); + + expect(commitAuthorLink).toEqual(`mailto:${pipelineWithoutAuthor.commit.author_email}`); + expect(commitAuthorName).toEqual(pipelineWithoutAuthor.commit.author_name); + }); + }); + + describe('stages column', () => { + beforeEach(() => { + wrapper = createWrapper(pipeline); + }); + + it('should render an icon for each stage', () => { + expect( + wrapper.findAll('.table-section:nth-child(4) .js-builds-dropdown-button').length, + ).toEqual(pipeline.details.stages.length); + }); + }); + + describe('actions column', () => { + const scheduledJobAction = { + name: 'some scheduled job', + }; + + beforeEach(() => { + const withActions = Object.assign({}, pipeline); + withActions.details.scheduled_actions = [scheduledJobAction]; + withActions.flags.cancelable = true; + withActions.flags.retryable = true; + withActions.cancel_path = '/cancel'; + withActions.retry_path = '/retry'; + + wrapper = createWrapper(withActions); + }); + + it('should render the provided actions', () => { + expect(wrapper.find('.js-pipelines-retry-button').exists()).toBe(true); + expect(wrapper.find('.js-pipelines-cancel-button').exists()).toBe(true); + const dropdownMenu = wrapper.find('.dropdown-menu'); + + expect(dropdownMenu.text()).toContain(scheduledJobAction.name); + }); + + it('emits `retryPipeline` event when retry button is clicked and toggles loading', () => { + eventHub.$on('retryPipeline', endpoint => { + expect(endpoint).toBe('/retry'); + }); + + wrapper.find('.js-pipelines-retry-button').trigger('click'); + expect(wrapper.vm.isRetrying).toBe(true); + }); + + it('emits `openConfirmationModal` event when cancel button is clicked and toggles loading', () => { + eventHub.$once('openConfirmationModal', data => { + const { id, ref, commit } = pipeline; + + expect(data.endpoint).toBe('/cancel'); + expect(data.pipeline).toEqual( + expect.objectContaining({ + id, + ref, + commit, + }), + ); + }); + + wrapper.find('.js-pipelines-cancel-button').trigger('click'); + }); + + it('renders a loading icon when `cancelingPipeline` matches pipeline id', done => { + wrapper.setProps({ cancelingPipeline: pipeline.id }); + wrapper.vm + .$nextTick() + .then(() => { + expect(wrapper.vm.isCancelling).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/mock_data.js b/spec/frontend/pipelines/test_reports/mock_data.js new file mode 100644 index 00000000000..b0f22bc63fb --- /dev/null +++ b/spec/frontend/pipelines/test_reports/mock_data.js @@ -0,0 +1,123 @@ +import { formatTime } from '~/lib/utils/datetime_utility'; +import { TestStatus } from '~/pipelines/constants'; + +export const testCases = [ + { + classname: 'spec.test_spec', + execution_time: 0.000748, + name: 'Test#subtract when a is 1 and b is 2 raises an error', + stack_trace: null, + status: TestStatus.SUCCESS, + system_output: null, + }, + { + classname: 'spec.test_spec', + execution_time: 0.000064, + name: 'Test#subtract when a is 2 and b is 1 returns correct result', + stack_trace: null, + status: TestStatus.SUCCESS, + system_output: null, + }, + { + classname: 'spec.test_spec', + execution_time: 0.009292, + name: 'Test#sum when a is 1 and b is 2 returns summary', + stack_trace: null, + status: TestStatus.FAILED, + system_output: + "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in <top (required)>'", + }, + { + classname: 'spec.test_spec', + execution_time: 0.00018, + name: 'Test#sum when a is 100 and b is 200 returns summary', + stack_trace: null, + status: TestStatus.FAILED, + system_output: + "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'", + }, + { + classname: 'spec.test_spec', + execution_time: 0, + name: 'Test#skipped text', + stack_trace: null, + status: TestStatus.SKIPPED, + system_output: null, + }, +]; + +export const testCasesFormatted = [ + { + ...testCases[2], + icon: 'status_failed_borderless', + formattedTime: formatTime(testCases[0].execution_time * 1000), + }, + { + ...testCases[3], + icon: 'status_failed_borderless', + formattedTime: formatTime(testCases[1].execution_time * 1000), + }, + { + ...testCases[4], + icon: 'status_skipped_borderless', + formattedTime: formatTime(testCases[2].execution_time * 1000), + }, + { + ...testCases[0], + icon: 'status_success_borderless', + formattedTime: formatTime(testCases[3].execution_time * 1000), + }, + { + ...testCases[1], + icon: 'status_success_borderless', + formattedTime: formatTime(testCases[4].execution_time * 1000), + }, +]; + +export const testSuites = [ + { + error_count: 0, + failed_count: 2, + name: 'rspec:osx', + skipped_count: 0, + success_count: 2, + test_cases: testCases, + total_count: 4, + total_time: 60, + }, + { + error_count: 0, + failed_count: 10, + name: 'rspec:osx', + skipped_count: 0, + success_count: 50, + test_cases: [], + total_count: 60, + total_time: 0.010284, + }, +]; + +export const testSuitesFormatted = testSuites.map(x => ({ + ...x, + formattedTime: formatTime(x.total_time * 1000), +})); + +export const testReports = { + error_count: 0, + failed_count: 2, + skipped_count: 0, + success_count: 2, + test_suites: testSuites, + total_count: 4, + total_time: 0.010284, +}; + +export const testReportsWithNoSuites = { + error_count: 0, + failed_count: 2, + skipped_count: 0, + success_count: 2, + test_suites: [], + total_count: 4, + total_time: 0.010284, +}; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js new file mode 100644 index 00000000000..c1721e12234 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -0,0 +1,109 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/pipelines/stores/test_reports/actions'; +import * as types from '~/pipelines/stores/test_reports/mutation_types'; +import { TEST_HOST } from '../../../helpers/test_constants'; +import testAction from '../../../helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { testReports } from '../mock_data'; + +jest.mock('~/flash.js'); + +describe('Actions TestReports Store', () => { + let mock; + let state; + + const endpoint = `${TEST_HOST}/test_reports.json`; + const defaultState = { + endpoint, + testReports: {}, + selectedSuite: {}, + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = defaultState; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('fetch reports', () => { + beforeEach(() => { + mock.onGet(`${TEST_HOST}/test_reports.json`).replyOnce(200, testReports, {}); + }); + + it('sets testReports and shows tests', done => { + testAction( + actions.fetchReports, + null, + state, + [{ type: types.SET_REPORTS, payload: testReports }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + done, + ); + }); + + it('should create flash on API error', done => { + testAction( + actions.fetchReports, + null, + { + endpoint: null, + }, + [], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + + describe('set selected suite', () => { + const selectedSuite = testReports.test_suites[0]; + + it('sets selectedSuite', done => { + testAction( + actions.setSelectedSuite, + selectedSuite, + state, + [{ type: types.SET_SELECTED_SUITE, payload: selectedSuite }], + [], + done, + ); + }); + }); + + describe('remove selected suite', () => { + it('sets selectedSuite to {}', done => { + testAction( + actions.removeSelectedSuite, + {}, + state, + [{ type: types.SET_SELECTED_SUITE, payload: {} }], + [], + done, + ); + }); + }); + + describe('toggles loading', () => { + it('sets isLoading to true', done => { + testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done); + }); + + it('toggles isLoading to false', done => { + testAction( + actions.toggleLoading, + {}, + { ...state, isLoading: true }, + [{ type: types.TOGGLE_LOADING }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js new file mode 100644 index 00000000000..e630a005409 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -0,0 +1,54 @@ +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { testReports, testSuitesFormatted, testCasesFormatted } from '../mock_data'; + +describe('Getters TestReports Store', () => { + let state; + + const defaultState = { + testReports, + selectedSuite: testReports.test_suites[0], + }; + + const emptyState = { + testReports: {}, + selectedSuite: {}, + }; + + beforeEach(() => { + state = { + testReports, + }; + }); + + const setupState = (testState = defaultState) => { + state = testState; + }; + + describe('getTestSuites', () => { + it('should return the test suites', () => { + setupState(); + + expect(getters.getTestSuites(state)).toEqual(testSuitesFormatted); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getTestSuites(state)).toEqual([]); + }); + }); + + describe('getSuiteTests', () => { + it('should return the test cases inside the suite', () => { + setupState(); + + expect(getters.getSuiteTests(state)).toEqual(testCasesFormatted); + }); + + it('should return an empty array when testReports is empty', () => { + setupState(emptyState); + + expect(getters.getSuiteTests(state)).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js new file mode 100644 index 00000000000..ad5b7f91163 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -0,0 +1,63 @@ +import * as types from '~/pipelines/stores/test_reports/mutation_types'; +import mutations from '~/pipelines/stores/test_reports/mutations'; +import { testReports, testSuites } from '../mock_data'; + +describe('Mutations TestReports Store', () => { + let mockState; + + const defaultState = { + endpoint: '', + testReports: {}, + selectedSuite: {}, + isLoading: false, + }; + + beforeEach(() => { + mockState = defaultState; + }); + + describe('set endpoint', () => { + it('should set endpoint', () => { + const expectedState = Object.assign({}, mockState, { endpoint: 'foo' }); + mutations[types.SET_ENDPOINT](mockState, 'foo'); + + expect(mockState.endpoint).toEqual(expectedState.endpoint); + }); + }); + + describe('set reports', () => { + it('should set testReports', () => { + const expectedState = Object.assign({}, mockState, { testReports }); + mutations[types.SET_REPORTS](mockState, testReports); + + expect(mockState.testReports).toEqual(expectedState.testReports); + }); + }); + + describe('set selected suite', () => { + it('should set selectedSuite', () => { + const expectedState = Object.assign({}, mockState, { selectedSuite: testSuites[0] }); + mutations[types.SET_SELECTED_SUITE](mockState, testSuites[0]); + + expect(mockState.selectedSuite).toEqual(expectedState.selectedSuite); + }); + }); + + describe('toggle loading', () => { + it('should set to true', () => { + const expectedState = Object.assign({}, mockState, { isLoading: true }); + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + + it('should toggle back to false', () => { + const expectedState = Object.assign({}, mockState, { isLoading: false }); + mockState.isLoading = true; + + mutations[types.TOGGLE_LOADING](mockState); + + expect(mockState.isLoading).toEqual(expectedState.isLoading); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js new file mode 100644 index 00000000000..4d6422745a9 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -0,0 +1,64 @@ +import Vuex from 'vuex'; +import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; +import { shallowMount } from '@vue/test-utils'; +import { testReports } from './mock_data'; +import * as actions from '~/pipelines/stores/test_reports/actions'; + +describe('Test reports app', () => { + let wrapper; + let store; + + const loadingSpinner = () => wrapper.find('.js-loading-spinner'); + const testsDetail = () => wrapper.find('.js-tests-detail'); + const noTestsToShow = () => wrapper.find('.js-no-tests-to-show'); + + const createComponent = (state = {}) => { + store = new Vuex.Store({ + state: { + isLoading: false, + selectedSuite: {}, + testReports, + ...state, + }, + actions, + }); + + wrapper = shallowMount(TestReports, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => createComponent({ isLoading: true })); + + it('shows the loading spinner', () => { + expect(noTestsToShow().exists()).toBe(false); + expect(testsDetail().exists()).toBe(false); + expect(loadingSpinner().exists()).toBe(true); + }); + }); + + describe('when the api returns no data', () => { + beforeEach(() => createComponent({ testReports: {} })); + + it('displays that there are no tests to show', () => { + const noTests = noTestsToShow(); + + expect(noTests.exists()).toBe(true); + expect(noTests.text()).toBe('There are no tests to show.'); + }); + }); + + describe('when the api returns data', () => { + beforeEach(() => createComponent()); + + it('sets testReports and shows tests', () => { + expect(wrapper.vm.testReports).toBeTruthy(); + expect(wrapper.vm.showTests).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js new file mode 100644 index 00000000000..b4305719ea8 --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -0,0 +1,77 @@ +import Vuex from 'vuex'; +import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue'; +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { TestStatus } from '~/pipelines/constants'; +import { shallowMount } from '@vue/test-utils'; +import { testSuites, testCases } from './mock_data'; + +describe('Test reports suite table', () => { + let wrapper; + let store; + + const noCasesMessage = () => wrapper.find('.js-no-test-cases'); + const allCaseRows = () => wrapper.findAll('.js-case-row'); + const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index); + const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); + + const createComponent = (suite = testSuites[0]) => { + store = new Vuex.Store({ + state: { + selectedSuite: suite, + }, + getters, + }); + + wrapper = shallowMount(SuiteTable, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('should not render', () => { + beforeEach(() => createComponent([])); + + it('a table when there are no test cases', () => { + expect(noCasesMessage().exists()).toBe(true); + }); + }); + + describe('when a test suite is supplied', () => { + beforeEach(() => createComponent()); + + it('renders the correct number of rows', () => { + expect(allCaseRows().length).toBe(testCases.length); + }); + + it('renders the failed tests first', () => { + const failedCaseNames = testCases + .filter(x => x.status === TestStatus.FAILED) + .map(x => x.name); + + const skippedCaseNames = testCases + .filter(x => x.status === TestStatus.SKIPPED) + .map(x => x.name); + + expect(findCaseRowAtIndex(0).text()).toContain(failedCaseNames[0]); + expect(findCaseRowAtIndex(1).text()).toContain(failedCaseNames[1]); + expect(findCaseRowAtIndex(2).text()).toContain(skippedCaseNames[0]); + }); + + it('renders the correct icon for each status', () => { + const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED); + const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED); + const successTest = testCases.findIndex(x => x.status === TestStatus.SUCCESS); + + const failedRow = findCaseRowAtIndex(failedTest); + const skippedRow = findCaseRowAtIndex(skippedTest); + const successRow = findCaseRowAtIndex(successTest); + + expect(findIconForRow(failedRow, TestStatus.FAILED).exists()).toBe(true); + expect(findIconForRow(skippedRow, TestStatus.SKIPPED).exists()).toBe(true); + expect(findIconForRow(successRow, TestStatus.SUCCESS).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js new file mode 100644 index 00000000000..19a7755dbdc --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -0,0 +1,78 @@ +import Summary from '~/pipelines/components/test_reports/test_summary.vue'; +import { mount } from '@vue/test-utils'; +import { testSuites } from './mock_data'; + +describe('Test reports summary', () => { + let wrapper; + + const backButton = () => wrapper.find('.js-back-button'); + const totalTests = () => wrapper.find('.js-total-tests'); + const failedTests = () => wrapper.find('.js-failed-tests'); + const erroredTests = () => wrapper.find('.js-errored-tests'); + const successRate = () => wrapper.find('.js-success-rate'); + const duration = () => wrapper.find('.js-duration'); + + const defaultProps = { + report: testSuites[0], + showBack: false, + }; + + const createComponent = props => { + wrapper = mount(Summary, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + describe('should not render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button by default', () => { + expect(backButton().exists()).toBe(false); + }); + }); + + describe('should render', () => { + beforeEach(() => { + createComponent(); + }); + + it('a back button and emit on-back-click event', () => { + createComponent({ + showBack: true, + }); + + expect(backButton().exists()).toBe(true); + }); + }); + + describe('when a report is supplied', () => { + beforeEach(() => { + createComponent(); + }); + + it('displays the correct total', () => { + expect(totalTests().text()).toBe('4 jobs'); + }); + + it('displays the correct failure count', () => { + expect(failedTests().text()).toBe('2 failures'); + }); + + it('displays the correct error count', () => { + expect(erroredTests().text()).toBe('0 errors'); + }); + + it('calculates and displays percentages correctly', () => { + expect(successRate().text()).toBe('50% success rate'); + }); + + it('displays the correctly formatted duration', () => { + expect(duration().text()).toBe('00:01:00'); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_summary_table_spec.js b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js new file mode 100644 index 00000000000..e7599d5cdbc --- /dev/null +++ b/spec/frontend/pipelines/test_reports/test_summary_table_spec.js @@ -0,0 +1,54 @@ +import Vuex from 'vuex'; +import SummaryTable from '~/pipelines/components/test_reports/test_summary_table.vue'; +import * as getters from '~/pipelines/stores/test_reports/getters'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { testReports, testReportsWithNoSuites } from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Test reports summary table', () => { + let wrapper; + let store; + + const allSuitesRows = () => wrapper.findAll('.js-suite-row'); + const noSuitesToShow = () => wrapper.find('.js-no-tests-suites'); + + const defaultProps = { + testReports, + }; + + const createComponent = (reports = null) => { + store = new Vuex.Store({ + state: { + testReports: reports || testReports, + }, + getters, + }); + + wrapper = mount(SummaryTable, { + propsData: defaultProps, + store, + localVue, + }); + }; + + describe('when test reports are supplied', () => { + beforeEach(() => createComponent()); + + it('renders the correct number of rows', () => { + expect(noSuitesToShow().exists()).toBe(false); + expect(allSuitesRows().length).toBe(testReports.test_suites.length); + }); + }); + + describe('when there are no test suites', () => { + beforeEach(() => { + createComponent({ testReportsWithNoSuites }); + }); + + it('displays the no suites to show message', () => { + expect(noSuitesToShow().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js index 8102033139f..e60f9f62747 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/project_find_file_spec.js @@ -3,6 +3,9 @@ import $ from 'jquery'; import ProjectFindFile from '~/project_find_file'; import axios from '~/lib/utils/axios_utils'; import { TEST_HOST } from 'helpers/test_constants'; +import sanitize from 'sanitize-html'; + +jest.mock('sanitize-html', () => jest.fn(val => val)); const BLOB_URL_TEMPLATE = `${TEST_HOST}/namespace/project/blob/master`; const FILE_FIND_URL = `${TEST_HOST}/namespace/project/files/master?format=json`; @@ -38,31 +41,31 @@ describe('ProjectFindFile', () => { href: el.querySelector('a').href, })); + const files = [ + 'fileA.txt', + 'fileB.txt', + 'fi#leC.txt', + 'folderA/fileD.txt', + 'folder#B/fileE.txt', + 'folde?rC/fil#F.txt', + ]; + beforeEach(() => { // Create a mock adapter for stubbing axios API requests mock = new MockAdapter(axios); element = $(TEMPLATE); + mock.onGet(FILE_FIND_URL).replyOnce(200, files); + getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor }); afterEach(() => { // Reset the mock adapter mock.restore(); + sanitize.mockClear(); }); it('loads and renders elements from remote server', done => { - const files = [ - 'fileA.txt', - 'fileB.txt', - 'fi#leC.txt', - 'folderA/fileD.txt', - 'folder#B/fileE.txt', - 'folde?rC/fil#F.txt', - ]; - mock.onGet(FILE_FIND_URL).replyOnce(200, files); - - getProjectFindFileInstance(); // This triggers a load / axios call + subsequent render in the constructor - setImmediate(() => { expect(findFiles()).toEqual( files.map(text => ({ @@ -74,4 +77,14 @@ describe('ProjectFindFile', () => { done(); }); }); + + it('sanitizes search text', done => { + const searchText = element.find('.file-finder-input').val(); + + setImmediate(() => { + expect(sanitize).toHaveBeenCalledTimes(1); + expect(sanitize).toHaveBeenCalledWith(searchText); + done(); + }); + }); }); diff --git a/spec/frontend/registry/components/collapsible_container_spec.js b/spec/frontend/registry/components/collapsible_container_spec.js index f93ebab1a4d..d035055afd3 100644 --- a/spec/frontend/registry/components/collapsible_container_spec.js +++ b/spec/frontend/registry/components/collapsible_container_spec.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; -import collapsibleComponent from '~/registry/components/collapsible_container.vue'; -import { repoPropsData } from '../mock_data'; import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import collapsibleComponent from '~/registry/components/collapsible_container.vue'; import * as getters from '~/registry/stores/getters'; +import { repoPropsData } from '../mock_data'; jest.mock('~/flash.js'); @@ -16,9 +17,10 @@ describe('collapsible registry container', () => { let wrapper; let store; - const findDeleteBtn = w => w.find('.js-remove-repo'); - const findContainerImageTags = w => w.find('.container-image-tags'); - const findToggleRepos = w => w.findAll('.js-toggle-repo'); + const findDeleteBtn = (w = wrapper) => w.find('.js-remove-repo'); + const findContainerImageTags = (w = wrapper) => w.find('.container-image-tags'); + const findToggleRepos = (w = wrapper) => w.findAll('.js-toggle-repo'); + const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' }); const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue }); @@ -124,4 +126,45 @@ describe('collapsible registry container', () => { expect(deleteBtn.exists()).toBe(false); }); }); + + describe('tracking', () => { + const category = 'mock_page'; + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + wrapper.vm.deleteItem = jest.fn().mockResolvedValue(); + wrapper.vm.fetchRepos = jest.fn(); + wrapper.setData({ + tracking: { + ...wrapper.vm.tracking, + category, + }, + }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteBtn(); + deleteBtn.trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'click_button', { + label: 'registry_repository_delete', + category, + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(category, 'cancel_delete', { + label: 'registry_repository_delete', + category, + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(category, 'confirm_delete', { + label: 'registry_repository_delete', + category, + }); + }); + }); }); diff --git a/spec/frontend/registry/components/table_registry_spec.js b/spec/frontend/registry/components/table_registry_spec.js index 7cb7c012d9d..ab88caf44e1 100644 --- a/spec/frontend/registry/components/table_registry_spec.js +++ b/spec/frontend/registry/components/table_registry_spec.js @@ -1,10 +1,14 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import tableRegistry from '~/registry/components/table_registry.vue'; import { mount, createLocalVue } from '@vue/test-utils'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import tableRegistry from '~/registry/components/table_registry.vue'; import { repoPropsData } from '../mock_data'; import * as getters from '~/registry/stores/getters'; +jest.mock('~/flash'); + const [firstImage, secondImage] = repoPropsData.list; const localVue = createLocalVue(); @@ -15,11 +19,12 @@ describe('table registry', () => { let wrapper; let store; - const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input'); - const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input'); - const findDeleteButton = w => w.find('.js-delete-registry'); - const findDeleteButtonsRow = w => w.findAll('.js-delete-registry-row'); - const findPagination = w => w.find('.js-registry-pagination'); + const findSelectAllCheckbox = (w = wrapper) => w.find('.js-select-all-checkbox > input'); + const findSelectCheckboxes = (w = wrapper) => w.findAll('.js-select-checkbox > input'); + const findDeleteButton = (w = wrapper) => w.find({ ref: 'bulkDeleteButton' }); + const findDeleteButtonsRow = (w = wrapper) => w.findAll('.js-delete-registry-row'); + const findPagination = (w = wrapper) => w.find('.js-registry-pagination'); + const findDeleteModal = (w = wrapper) => w.find({ ref: 'deleteModal' }); const bulkDeletePath = 'path'; const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue }); @@ -139,7 +144,7 @@ describe('table registry', () => { }, }); wrapper.vm.handleMultipleDelete(); - expect(wrapper.vm.showError).toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); @@ -169,6 +174,27 @@ describe('table registry', () => { }); }); + describe('modal event handlers', () => { + beforeEach(() => { + wrapper.vm.handleSingleDelete = jest.fn(); + wrapper.vm.handleMultipleDelete = jest.fn(); + }); + it('on ok when one item is selected should call singleDelete', () => { + wrapper.setData({ itemsToBeDeleted: [0] }); + wrapper.vm.onDeletionConfirmed(); + + expect(wrapper.vm.handleSingleDelete).toHaveBeenCalledWith(repoPropsData.list[0]); + expect(wrapper.vm.handleMultipleDelete).not.toHaveBeenCalled(); + }); + it('on ok when multiple items are selected should call muultiDelete', () => { + wrapper.setData({ itemsToBeDeleted: [0, 1, 2] }); + wrapper.vm.onDeletionConfirmed(); + + expect(wrapper.vm.handleMultipleDelete).toHaveBeenCalled(); + expect(wrapper.vm.handleSingleDelete).not.toHaveBeenCalled(); + }); + }); + describe('pagination', () => { const repo = { repoPropsData, @@ -265,4 +291,83 @@ describe('table registry', () => { expect(deleteBtns.length).toBe(0); }); }); + + describe('event tracking', () => { + const mockPageName = 'mock_page'; + + beforeEach(() => { + jest.spyOn(Tracking, 'event'); + wrapper.vm.handleSingleDelete = jest.fn(); + wrapper.vm.handleMultipleDelete = jest.fn(); + document.body.dataset.page = mockPageName; + }); + + afterEach(() => { + document.body.dataset.page = null; + }); + + describe('single tag delete', () => { + beforeEach(() => { + wrapper.setData({ itemsToBeDeleted: [0] }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteButtonsRow(); + deleteBtn.at(0).trigger('click'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', { + label: 'registry_tag_delete', + property: 'foo', + }); + }); + }); + describe('bulk tag delete', () => { + beforeEach(() => { + const items = [0, 1, 2]; + wrapper.setData({ itemsToBeDeleted: items, selectedItems: items }); + }); + + it('send an event when delete button is clicked', () => { + const deleteBtn = findDeleteButton(); + deleteBtn.vm.$emit('click'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'click_button', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when cancel is pressed on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('cancel'); + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'cancel_delete', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + it('send an event when confirm is clicked on modal', () => { + const deleteModal = findDeleteModal(); + deleteModal.vm.$emit('ok'); + + expect(Tracking.event).toHaveBeenCalledWith(mockPageName, 'confirm_delete', { + label: 'bulk_registry_tag_delete', + property: 'foo', + }); + }); + }); + }); }); diff --git a/spec/frontend/releases/detail/components/app_spec.js b/spec/frontend/releases/detail/components/app_spec.js index f8eb33a69a8..4726f18c8fa 100644 --- a/spec/frontend/releases/detail/components/app_spec.js +++ b/spec/frontend/releases/detail/components/app_spec.js @@ -8,15 +8,17 @@ describe('Release detail component', () => { let wrapper; let releaseClone; let actions; + let state; beforeEach(() => { gon.api_version = 'v4'; releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release))); - const state = { + state = { release: releaseClone, markdownDocsPath: 'path/to/markdown/docs', + updateReleaseApiDocsPath: 'path/to/update/release/api/docs', }; actions = { @@ -46,6 +48,21 @@ describe('Release detail component', () => { expect(wrapper.find('#git-ref').element.value).toBe(releaseClone.tagName); }); + it('renders the correct help text under the "Tag name" field', () => { + const helperText = wrapper.find('#tag-name-help'); + const helperTextLink = helperText.find('a'); + const helperTextLinkAttrs = helperTextLink.attributes(); + + expect(helperText.text()).toBe( + 'Changing a Release tag is only supported via Releases API. More information', + ); + expect(helperTextLink.text()).toBe('More information'); + expect(helperTextLinkAttrs.href).toBe(state.updateReleaseApiDocsPath); + expect(helperTextLinkAttrs.rel).toContain('noopener'); + expect(helperTextLinkAttrs.rel).toContain('noreferrer'); + expect(helperTextLinkAttrs.target).toBe('_blank'); + }); + it('renders the correct release title in the "Release title" field', () => { expect(wrapper.find('#release-title').element.value).toBe(releaseClone.name); }); diff --git a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap b/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap deleted file mode 100644 index 8f2c0427c83..00000000000 --- a/spec/frontend/releases/list/components/__snapshots__/release_block_spec.js.snap +++ /dev/null @@ -1,332 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Release block with default props matches the snapshot 1`] = ` -<div - class="card release-block" - id="v0.3" -> - <div - class="card-body" - > - <div - class="d-flex align-items-start" - > - <h2 - class="card-title mt-0 mr-auto" - > - - New release - - <!----> - </h2> - - <a - class="btn btn-default js-edit-button ml-2" - data-original-title="Edit this release" - href="http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit" - title="" - > - <svg - aria-hidden="true" - class="s16 ic-pencil" - > - <use - xlink:href="#pencil" - /> - </svg> - </a> - </div> - - <div - class="card-subtitle d-flex flex-wrap text-secondary" - > - <div - class="append-right-8" - > - <svg - aria-hidden="true" - class="align-middle s16 ic-commit" - > - <use - xlink:href="#commit" - /> - </svg> - - <span - data-original-title="Initial commit" - title="" - > - c22b0728 - </span> - </div> - - <div - class="append-right-8" - > - <svg - aria-hidden="true" - class="align-middle s16 ic-tag" - > - <use - xlink:href="#tag" - /> - </svg> - - <span - data-original-title="Tag" - title="" - > - v0.3 - </span> - </div> - - <div - class="js-milestone-list-label" - > - <svg - aria-hidden="true" - class="align-middle s16 ic-flag" - > - <use - xlink:href="#flag" - /> - </svg> - - <span - class="js-label-text" - > - Milestones - </span> - </div> - - <a - class="append-right-4 prepend-left-4 js-milestone-link" - data-original-title="The 13.6 milestone!" - href="http://0.0.0.0:3001/root/release-test/-/milestones/2" - title="" - > - - 13.6 - - </a> - - • - - <a - class="append-right-4 prepend-left-4 js-milestone-link" - data-original-title="The 13.5 milestone!" - href="http://0.0.0.0:3001/root/release-test/-/milestones/1" - title="" - > - - 13.5 - - </a> - - <!----> - - <div - class="append-right-4" - > - - • - - <span - data-original-title="Aug 26, 2019 5:54pm GMT+0000" - title="" - > - - released 1 month ago - - </span> - </div> - - <div - class="d-flex" - > - - by - - <a - class="user-avatar-link prepend-left-4" - href="" - > - <span> - <img - alt="root's avatar" - class="avatar s20 " - data-original-title="" - data-src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" - height="20" - src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" - title="" - width="20" - /> - - <div - aria-hidden="true" - class="js-user-avatar-image-toolip d-none" - style="display: none;" - > - <div> - root - </div> - </div> - </span> - <!----> - </a> - </div> - </div> - - <div - class="card-text prepend-top-default" - > - <b> - - Assets - - <span - class="js-assets-count badge badge-pill" - > - 5 - </span> - </b> - - <ul - class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list" - > - <li - class="append-bottom-8" - > - <a - class="" - data-original-title="Download asset" - href="https://google.com" - title="" - > - <svg - aria-hidden="true" - class="align-middle append-right-4 align-text-bottom s16 ic-package" - > - <use - xlink:href="#package" - /> - </svg> - - my link - - <span> - (external source) - </span> - </a> - </li> - <li - class="append-bottom-8" - > - <a - class="" - data-original-title="Download asset" - href="https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50" - title="" - > - <svg - aria-hidden="true" - class="align-middle append-right-4 align-text-bottom s16 ic-package" - > - <use - xlink:href="#package" - /> - </svg> - - my second link - - <!----> - </a> - </li> - </ul> - - <div - class="dropdown" - > - <button - aria-expanded="false" - aria-haspopup="true" - class="btn btn-link" - data-toggle="dropdown" - type="button" - > - <svg - aria-hidden="true" - class="align-top append-right-4 s16 ic-doc-code" - > - <use - xlink:href="#doc-code" - /> - </svg> - - Source code - - <svg - aria-hidden="true" - class="s16 ic-arrow-down" - > - <use - xlink:href="#arrow-down" - /> - </svg> - </button> - - <div - class="js-sources-dropdown dropdown-menu" - > - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip" - > - Download zip - </a> - </li> - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz" - > - Download tar.gz - </a> - </li> - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2" - > - Download tar.bz2 - </a> - </li> - <li> - <a - class="" - href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar" - > - Download tar - </a> - </li> - </div> - </div> - </div> - - <div - class="card-text prepend-top-default" - > - <div> - <p - data-sourcepos="1:1-1:21" - dir="auto" - > - A super nice release! - </p> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/releases/list/components/release_block_footer_spec.js b/spec/frontend/releases/list/components/release_block_footer_spec.js new file mode 100644 index 00000000000..172147f1cc8 --- /dev/null +++ b/spec/frontend/releases/list/components/release_block_footer_spec.js @@ -0,0 +1,163 @@ +import { mount } from '@vue/test-utils'; +import ReleaseBlockFooter from '~/releases/list/components/release_block_footer.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import { GlLink } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { release } from '../../mock_data'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +jest.mock('~/vue_shared/mixins/timeago', () => ({ + methods: { + timeFormated() { + return '7 fortnightes ago'; + }, + tooltipTitle() { + return 'February 30, 2401'; + }, + }, +})); + +describe('Release block footer', () => { + let wrapper; + let releaseClone; + + const factory = (props = {}) => { + wrapper = mount(ReleaseBlockFooter, { + propsData: { + ...convertObjectPropsToCamelCase(releaseClone), + ...props, + }, + sync: false, + }); + + return wrapper.vm.$nextTick(); + }; + + beforeEach(() => { + releaseClone = JSON.parse(JSON.stringify(release)); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const commitInfoSection = () => wrapper.find('.js-commit-info'); + const commitInfoSectionLink = () => commitInfoSection().find(GlLink); + const tagInfoSection = () => wrapper.find('.js-tag-info'); + const tagInfoSectionLink = () => tagInfoSection().find(GlLink); + const authorDateInfoSection = () => wrapper.find('.js-author-date-info'); + + describe('with all props provided', () => { + beforeEach(() => factory()); + + it('renders the commit icon', () => { + const commitIcon = commitInfoSection().find(Icon); + + expect(commitIcon.exists()).toBe(true); + expect(commitIcon.props('name')).toBe('commit'); + }); + + it('renders the commit SHA with a link', () => { + const commitLink = commitInfoSectionLink(); + + expect(commitLink.exists()).toBe(true); + expect(commitLink.text()).toBe(releaseClone.commit.short_id); + expect(commitLink.attributes('href')).toBe(releaseClone.commit_path); + }); + + it('renders the tag icon', () => { + const commitIcon = tagInfoSection().find(Icon); + + expect(commitIcon.exists()).toBe(true); + expect(commitIcon.props('name')).toBe('tag'); + }); + + it('renders the tag name with a link', () => { + const commitLink = tagInfoSection().find(GlLink); + + expect(commitLink.exists()).toBe(true); + expect(commitLink.text()).toBe(releaseClone.tag_name); + expect(commitLink.attributes('href')).toBe(releaseClone.tag_path); + }); + + it('renders the author and creation time info', () => { + expect(trimText(authorDateInfoSection().text())).toBe( + `Created 7 fortnightes ago by ${releaseClone.author.username}`, + ); + }); + + it("renders the author's avatar image", () => { + const avatarImg = authorDateInfoSection().find('img'); + + expect(avatarImg.exists()).toBe(true); + expect(avatarImg.attributes('src')).toBe(releaseClone.author.avatar_url); + }); + + it("renders a link to the author's profile", () => { + const authorLink = authorDateInfoSection().find(GlLink); + + expect(authorLink.exists()).toBe(true); + expect(authorLink.attributes('href')).toBe(releaseClone.author.web_url); + }); + }); + + describe('without any commit info', () => { + beforeEach(() => factory({ commit: undefined })); + + it('does not render any commit info', () => { + expect(commitInfoSection().exists()).toBe(false); + }); + }); + + describe('without a commit URL', () => { + beforeEach(() => factory({ commitPath: undefined })); + + it('renders the commit SHA as plain text (instead of a link)', () => { + expect(commitInfoSectionLink().exists()).toBe(false); + expect(commitInfoSection().text()).toBe(releaseClone.commit.short_id); + }); + }); + + describe('without a tag name', () => { + beforeEach(() => factory({ tagName: undefined })); + + it('does not render any tag info', () => { + expect(tagInfoSection().exists()).toBe(false); + }); + }); + + describe('without a tag URL', () => { + beforeEach(() => factory({ tagPath: undefined })); + + it('renders the tag name as plain text (instead of a link)', () => { + expect(tagInfoSectionLink().exists()).toBe(false); + expect(tagInfoSection().text()).toBe(releaseClone.tag_name); + }); + }); + + describe('without any author info', () => { + beforeEach(() => factory({ author: undefined })); + + it('renders the release date without the author name', () => { + expect(trimText(authorDateInfoSection().text())).toBe('Created 7 fortnightes ago'); + }); + }); + + describe('without a released at date', () => { + beforeEach(() => factory({ releasedAt: undefined })); + + it('renders the author name without the release date', () => { + expect(trimText(authorDateInfoSection().text())).toBe( + `Created by ${releaseClone.author.username}`, + ); + }); + }); + + describe('without a release date or author info', () => { + beforeEach(() => factory({ author: undefined, releasedAt: undefined })); + + it('does not render any author or release date info', () => { + expect(authorDateInfoSection().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/releases/list/components/release_block_spec.js b/spec/frontend/releases/list/components/release_block_spec.js index ac51c3af11a..b63ef068d8e 100644 --- a/spec/frontend/releases/list/components/release_block_spec.js +++ b/spec/frontend/releases/list/components/release_block_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import ReleaseBlock from '~/releases/list/components/release_block.vue'; +import ReleaseBlockFooter from '~/releases/list/components/release_block_footer.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { first } from 'underscore'; import { release } from '../../mock_data'; @@ -21,14 +22,16 @@ describe('Release block', () => { let wrapper; let releaseClone; - const factory = (releaseProp, releaseEditPageFeatureFlag = true) => { + const factory = (releaseProp, featureFlags = {}) => { wrapper = mount(ReleaseBlock, { propsData: { release: releaseProp, }, provide: { glFeatures: { - releaseEditPage: releaseEditPageFeatureFlag, + releaseEditPage: true, + releaseIssueSummary: true, + ...featureFlags, }, }, sync: false, @@ -39,41 +42,25 @@ describe('Release block', () => { const milestoneListLabel = () => wrapper.find('.js-milestone-list-label'); const editButton = () => wrapper.find('.js-edit-button'); - const RealDate = Date; beforeEach(() => { - // timeago.js calls Date(), so let's mock that case to avoid time-dependent test failures. - const constantDate = new Date('2019-10-25T00:12:00'); - - /* eslint no-global-assign:off */ - global.Date = jest.fn((...props) => - props.length ? new RealDate(...props) : new RealDate(constantDate), - ); - - Object.assign(Date, RealDate); - releaseClone = JSON.parse(JSON.stringify(release)); }); afterEach(() => { wrapper.destroy(); - global.Date = RealDate; }); describe('with default props', () => { beforeEach(() => factory(release)); - it('matches the snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - it("renders the block with an id equal to the release's tag name", () => { expect(wrapper.attributes().id).toBe('v0.3'); }); it('renders an edit button that links to the "Edit release" page', () => { expect(editButton().exists()).toBe(true); - expect(editButton().attributes('href')).toBe(release._links.edit); + expect(editButton().attributes('href')).toBe(release._links.edit_url); }); it('renders release name', () => { @@ -158,6 +145,10 @@ describe('Release block', () => { expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description); }); + + it('renders the footer', () => { + expect(wrapper.find(ReleaseBlockFooter).exists()).toBe(true); + }); }); it('renders commit sha', () => { @@ -180,7 +171,7 @@ describe('Release block', () => { }); }); - it("does not render an edit button if release._links.edit isn't a string", () => { + it("does not render an edit button if release._links.edit_url isn't a string", () => { delete releaseClone._links; return factory(releaseClone).then(() => { @@ -189,7 +180,7 @@ describe('Release block', () => { }); it('does not render an edit button if the releaseEditPage feature flag is disabled', () => - factory(releaseClone, false).then(() => { + factory(releaseClone, { releaseEditPage: false }).then(() => { expect(editButton().exists()).toBe(false); })); diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js index b2ebf1174d4..61d95b86b1c 100644 --- a/spec/frontend/releases/mock_data.js +++ b/spec/frontend/releases/mock_data.js @@ -30,6 +30,7 @@ export const milestones = [ export const release = { name: 'New release', tag_name: 'v0.3', + tag_path: '/root/release-test/-/tags/v0.3', description: 'A super nice release!', description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>', created_at: '2019-08-26T17:54:04.952Z', @@ -56,6 +57,7 @@ export const release = { committer_email: 'admin@example.com', committed_date: '2019-08-26T17:47:07.000Z', }, + commit_path: '/root/release-test/commit/c22b0728d1b465f82898c884d32b01aa642f96c1', upcoming_release: false, milestones, assets: { @@ -95,6 +97,6 @@ export const release = { ], }, _links: { - edit: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit', + edit_url: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit', }, }; diff --git a/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap new file mode 100644 index 00000000000..31a1cd23060 --- /dev/null +++ b/spec/frontend/repository/components/__snapshots__/directory_download_links_spec.js.snap @@ -0,0 +1,75 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Repository directory download links component renders downloads links for path app 1`] = ` +<section + class="border-top pt-1 mt-1" +> + <h5 + class="m-0 dropdown-bold-header" + > + Download this directory + </h5> + + <div + class="dropdown-menu-content" + > + <div + class="btn-group ml-0 w-100" + > + <gllink-stub + class="btn btn-xs btn-primary" + href="http://test.com/?path=app" + > + + zip + + </gllink-stub> + <gllink-stub + class="btn btn-xs" + href="http://test.com/?path=app" + > + + tar + + </gllink-stub> + </div> + </div> +</section> +`; + +exports[`Repository directory download links component renders downloads links for path app/assets 1`] = ` +<section + class="border-top pt-1 mt-1" +> + <h5 + class="m-0 dropdown-bold-header" + > + Download this directory + </h5> + + <div + class="dropdown-menu-content" + > + <div + class="btn-group ml-0 w-100" + > + <gllink-stub + class="btn btn-xs btn-primary" + href="http://test.com/?path=app/assets" + > + + zip + + </gllink-stub> + <gllink-stub + class="btn btn-xs" + href="http://test.com/?path=app/assets" + > + + tar + + </gllink-stub> + </div> + </div> +</section> +`; diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 08173f4f0c4..706c26403c0 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -62,19 +62,23 @@ exports[`Repository last commit component renders commit widget 1`] = ` > <!----> - <gllink-stub - class="js-commit-pipeline" - data-original-title="Commit: failed" - href="https://test.com/pipeline" - title="" + <div + class="ci-status-link" > - <ciicon-stub - aria-label="Commit: failed" - cssclasses="" - size="24" - status="[object Object]" - /> - </gllink-stub> + <gllink-stub + class="js-commit-pipeline" + data-original-title="Commit: failed" + href="https://test.com/pipeline" + title="" + > + <ciicon-stub + aria-label="Commit: failed" + cssclasses="" + size="24" + status="[object Object]" + /> + </gllink-stub> + </div> <div class="commit-sha-group d-flex" @@ -165,19 +169,23 @@ exports[`Repository last commit component renders the signature HTML as returned </button> </div> - <gllink-stub - class="js-commit-pipeline" - data-original-title="Commit: failed" - href="https://test.com/pipeline" - title="" + <div + class="ci-status-link" > - <ciicon-stub - aria-label="Commit: failed" - cssclasses="" - size="24" - status="[object Object]" - /> - </gllink-stub> + <gllink-stub + class="js-commit-pipeline" + data-original-title="Commit: failed" + href="https://test.com/pipeline" + title="" + > + <ciicon-stub + aria-label="Commit: failed" + cssclasses="" + size="24" + status="[object Object]" + /> + </gllink-stub> + </div> <div class="commit-sha-group d-flex" diff --git a/spec/frontend/repository/components/directory_download_links_spec.js b/spec/frontend/repository/components/directory_download_links_spec.js new file mode 100644 index 00000000000..4d70b44de08 --- /dev/null +++ b/spec/frontend/repository/components/directory_download_links_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import DirectoryDownloadLinks from '~/repository/components/directory_download_links.vue'; + +let vm; + +function factory(currentPath) { + vm = shallowMount(DirectoryDownloadLinks, { + propsData: { + currentPath, + links: [{ text: 'zip', path: 'http://test.com/' }, { text: 'tar', path: 'http://test.com/' }], + }, + }); +} + +describe('Repository directory download links component', () => { + afterEach(() => { + vm.destroy(); + }); + + it.each` + path + ${'app'} + ${'app/assets'} + `('renders downloads links for path $path', ({ path }) => { + factory(path); + + expect(vm.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index 01b56d453e6..e07ad4cf46b 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -17,7 +17,7 @@ function createCommitData(data = {}) { avatarUrl: 'https://test.com', webUrl: 'https://test.com/test', }, - latestPipeline: { + pipeline: { detailedStatus: { detailsPath: 'https://test.com/pipeline', icon: 'failed', @@ -74,7 +74,7 @@ describe('Repository last commit component', () => { }); it('hides pipeline components when pipeline does not exist', () => { - factory(createCommitData({ latestPipeline: null })); + factory(createCommitData({ pipeline: null })); expect(vm.find('.js-commit-pipeline').exists()).toBe(false); }); diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap new file mode 100644 index 00000000000..a5e3eb4bce1 --- /dev/null +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Repository file preview component renders file HTML 1`] = ` +<article + class="file-holder limited-width-container readme-holder" +> + <div + class="file-title" + > + <i + aria-hidden="true" + class="fa fa-file-text-o fa-fw" + /> + + <gllink-stub + href="http://test.com" + > + <strong> + README.md + </strong> + </gllink-stub> + </div> + + <div + class="blob-viewer" + > + <div> + <div + class="blob" + > + test + </div> + </div> + </div> +</article> +`; diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js new file mode 100644 index 00000000000..0112e6310f4 --- /dev/null +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Preview from '~/repository/components/preview/index.vue'; + +let vm; +let $apollo; + +function factory(blob) { + $apollo = { + query: jest.fn().mockReturnValue(Promise.resolve({})), + }; + + vm = shallowMount(Preview, { + propsData: { + blob, + }, + mocks: { + $apollo, + }, + }); +} + +describe('Repository file preview component', () => { + afterEach(() => { + vm.destroy(); + }); + + it('renders file HTML', () => { + factory({ + webUrl: 'http://test.com', + name: 'README.md', + }); + + vm.setData({ readme: { html: '<div class="blob">test</div>' } }); + + expect(vm.element).toMatchSnapshot(); + }); + + it('renders loading icon', () => { + factory({ + webUrl: 'http://test.com', + name: 'README.md', + }); + + vm.setData({ loading: 1 }); + + expect(vm.find(GlLoadingIcon).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap index d55dc553031..f8e65a51297 100644 --- a/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap +++ b/spec/frontend/repository/components/table/__snapshots__/row_spec.js.snap @@ -25,6 +25,8 @@ exports[`Repository table row component renders table row 1`] = ` <!----> <!----> + + <!----> </td> <td diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 827927e6d9a..41450becabb 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -1,18 +1,36 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlSkeletonLoading } from '@gitlab/ui'; import Table from '~/repository/components/table/index.vue'; +import TableRow from '~/repository/components/table/row.vue'; let vm; let $apollo; -function factory(path, data = () => ({})) { - $apollo = { - query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), - }; - +const MOCK_BLOBS = [ + { + id: '123abc', + sha: '123abc', + flatPath: 'blob', + name: 'blob.md', + type: 'blob', + webUrl: 'http://test.com', + }, + { + id: '124abc', + sha: '124abc', + flatPath: 'blob2', + name: 'blob2.md', + type: 'blob', + webUrl: 'http://test.com', + }, +]; + +function factory({ path, isLoading = false, entries = {} }) { vm = shallowMount(Table, { propsData: { path, + isLoading, + entries, }, mocks: { $apollo, @@ -31,50 +49,30 @@ describe('Repository table component', () => { ${'app/assets'} | ${'master'} ${'/'} | ${'test'} `('renders table caption for $ref in $path', ({ path, ref }) => { - factory(path); + factory({ path }); vm.setData({ ref }); - expect(vm.find('caption').text()).toEqual( + expect(vm.find('.table').attributes('aria-label')).toEqual( `Files, directories, and submodules in the path ${path} for commit reference ${ref}`, ); }); it('shows loading icon', () => { - factory('/'); - - vm.setData({ isLoadingFiles: true }); + factory({ path: '/', isLoading: true }); - expect(vm.find(GlLoadingIcon).isVisible()).toBe(true); + expect(vm.find(GlSkeletonLoading).exists()).toBe(true); }); - describe('normalizeData', () => { - it('normalizes edge nodes', () => { - const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); - - expect(output).toEqual(['1', '2']); + it('renders table rows', () => { + factory({ + path: '/', + entries: { + blobs: MOCK_BLOBS, + }, }); - }); - - describe('hasNextPage', () => { - it('returns undefined when hasNextPage is false', () => { - const output = vm.vm.hasNextPage({ - trees: { pageInfo: { hasNextPage: false } }, - submodules: { pageInfo: { hasNextPage: false } }, - blobs: { pageInfo: { hasNextPage: false } }, - }); - expect(output).toBe(undefined); - }); - - it('returns pageInfo object when hasNextPage is true', () => { - const output = vm.vm.hasNextPage({ - trees: { pageInfo: { hasNextPage: false } }, - submodules: { pageInfo: { hasNextPage: false } }, - blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } }, - }); - - expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' }); - }); + expect(vm.find(TableRow).exists()).toBe(true); + expect(vm.findAll(TableRow).length).toBe(2); }); }); diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index e539c560975..aa0b9385f1a 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -2,6 +2,7 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import { GlBadge, GlLink } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TableRow from '~/repository/components/table/row.vue'; +import Icon from '~/vue_shared/components/icon.vue'; jest.mock('~/lib/utils/url_utility'); @@ -40,6 +41,7 @@ describe('Repository table row component', () => { it('renders table row', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'file', currentPath: '/', @@ -56,6 +58,7 @@ describe('Repository table row component', () => { `('renders a $componentName for type $type', ({ type, component }) => { factory({ id: '1', + sha: '123', path: 'test', type, currentPath: '/', @@ -72,6 +75,7 @@ describe('Repository table row component', () => { `('pushes new router if type $type is tree', ({ type, pushes }) => { factory({ id: '1', + sha: '123', path: 'test', type, currentPath: '/', @@ -94,6 +98,7 @@ describe('Repository table row component', () => { `('calls visitUrl if $type is not tree', ({ type, pushes }) => { factory({ id: '1', + sha: '123', path: 'test', type, currentPath: '/', @@ -104,13 +109,14 @@ describe('Repository table row component', () => { if (pushes) { expect(visitUrl).not.toHaveBeenCalled(); } else { - expect(visitUrl).toHaveBeenCalledWith('https://test.com'); + expect(visitUrl).toHaveBeenCalledWith('https://test.com', undefined); } }); it('renders commit ID for submodule', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'commit', currentPath: '/', @@ -122,6 +128,7 @@ describe('Repository table row component', () => { it('renders link with href', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'blob', url: 'https://test.com', @@ -134,6 +141,7 @@ describe('Repository table row component', () => { it('renders LFS badge', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'commit', currentPath: '/', @@ -146,6 +154,7 @@ describe('Repository table row component', () => { it('renders commit and web links with href for submodule', () => { factory({ id: '1', + sha: '123', path: 'test', type: 'commit', url: 'https://test.com', @@ -156,4 +165,18 @@ describe('Repository table row component', () => { expect(vm.find('a').attributes('href')).toEqual('https://test.com'); expect(vm.find(GlLink).attributes('href')).toEqual('https://test.com/commit'); }); + + it('renders lock icon', () => { + factory({ + id: '1', + sha: '123', + path: 'test', + type: 'tree', + currentPath: '/', + }); + + vm.setData({ commit: { lockLabel: 'Locked by Root', committedDate: '2019-01-01' } }); + + expect(vm.find(Icon).exists()).toBe(true); + }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js new file mode 100644 index 00000000000..148e307a5d4 --- /dev/null +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import TreeContent from '~/repository/components/tree_content.vue'; +import FilePreview from '~/repository/components/preview/index.vue'; + +let vm; +let $apollo; + +function factory(path, data = () => ({})) { + $apollo = { + query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), + }; + + vm = shallowMount(TreeContent, { + propsData: { + path, + }, + mocks: { + $apollo, + }, + }); +} + +describe('Repository table component', () => { + afterEach(() => { + vm.destroy(); + }); + + it('renders file preview', () => { + factory('/'); + + vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); + + expect(vm.find(FilePreview).exists()).toBe(true); + }); + + describe('normalizeData', () => { + it('normalizes edge nodes', () => { + factory('/'); + + const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); + + expect(output).toEqual(['1', '2']); + }); + }); + + describe('hasNextPage', () => { + it('returns undefined when hasNextPage is false', () => { + factory('/'); + + const output = vm.vm.hasNextPage({ + trees: { pageInfo: { hasNextPage: false } }, + submodules: { pageInfo: { hasNextPage: false } }, + blobs: { pageInfo: { hasNextPage: false } }, + }); + + expect(output).toBe(undefined); + }); + + it('returns pageInfo object when hasNextPage is true', () => { + factory('/'); + + const output = vm.vm.hasNextPage({ + trees: { pageInfo: { hasNextPage: false } }, + submodules: { pageInfo: { hasNextPage: false } }, + blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } }, + }); + + expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' }); + }); + }); +}); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index a3a766eca41..9199c726680 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import { normalizeData, resolveCommit, fetchLogsTree } from '~/repository/log_tree'; +import { resolveCommit, fetchLogsTree } from '~/repository/log_tree'; const mockData = [ { @@ -15,22 +15,6 @@ const mockData = [ }, ]; -describe('normalizeData', () => { - it('normalizes data into LogTreeCommit object', () => { - expect(normalizeData(mockData)).toEqual([ - { - sha: '123', - message: 'testing message', - committedDate: '2019-01-01', - commitPath: 'https://test.com', - fileName: 'index.js', - type: 'blob', - __typename: 'LogTreeCommit', - }, - ]); - }); -}); - describe('resolveCommit', () => { it('calls resolve when commit found', () => { const resolver = { @@ -57,7 +41,7 @@ describe('fetchLogsTree', () => { jest.spyOn(axios, 'get'); - global.gon = { gitlab_url: 'https://test.com' }; + global.gon = { relative_url_root: '' }; client = { readQuery: () => ({ @@ -80,10 +64,9 @@ describe('fetchLogsTree', () => { it('calls axios get', () => fetchLogsTree(client, '', '0', resolver).then(() => { - expect(axios.get).toHaveBeenCalledWith( - 'https://test.com/gitlab-org/gitlab-foss/refs/master/logs_tree', - { params: { format: 'json', offset: '0' } }, - ); + expect(axios.get).toHaveBeenCalledWith('/gitlab-org/gitlab-foss/refs/master/logs_tree/', { + params: { format: 'json', offset: '0' }, + }); })); it('calls axios get once', () => diff --git a/spec/frontend/repository/pages/index_spec.js b/spec/frontend/repository/pages/index_spec.js new file mode 100644 index 00000000000..c0afb7931b1 --- /dev/null +++ b/spec/frontend/repository/pages/index_spec.js @@ -0,0 +1,42 @@ +import { shallowMount } from '@vue/test-utils'; +import IndexPage from '~/repository/pages/index.vue'; +import TreePage from '~/repository/pages/tree.vue'; +import { updateElementsVisibility } from '~/repository/utils/dom'; + +jest.mock('~/repository/utils/dom'); + +describe('Repository index page component', () => { + let wrapper; + + function factory() { + wrapper = shallowMount(IndexPage); + } + + afterEach(() => { + wrapper.destroy(); + + updateElementsVisibility.mockClear(); + }); + + it('calls updateElementsVisibility on mounted', () => { + factory(); + + expect(updateElementsVisibility).toHaveBeenCalledWith('.js-show-on-project-root', true); + }); + + it('calls updateElementsVisibility after destroy', () => { + factory(); + wrapper.destroy(); + + expect(updateElementsVisibility.mock.calls.pop()).toEqual(['.js-show-on-project-root', false]); + }); + + it('renders TreePage', () => { + factory(); + + const child = wrapper.find(TreePage); + + expect(child.exists()).toBe(true); + expect(child.props()).toEqual({ path: '/' }); + }); +}); diff --git a/spec/frontend/repository/pages/tree_spec.js b/spec/frontend/repository/pages/tree_spec.js new file mode 100644 index 00000000000..36662696c91 --- /dev/null +++ b/spec/frontend/repository/pages/tree_spec.js @@ -0,0 +1,60 @@ +import { shallowMount } from '@vue/test-utils'; +import TreePage from '~/repository/pages/tree.vue'; +import { updateElementsVisibility } from '~/repository/utils/dom'; + +jest.mock('~/repository/utils/dom'); + +describe('Repository tree page component', () => { + let wrapper; + + function factory(path) { + wrapper = shallowMount(TreePage, { propsData: { path } }); + } + + afterEach(() => { + wrapper.destroy(); + + updateElementsVisibility.mockClear(); + }); + + describe('when root path', () => { + beforeEach(() => { + factory('/'); + }); + + it('shows root elements', () => { + expect(updateElementsVisibility.mock.calls).toEqual([ + ['.js-show-on-root', true], + ['.js-hide-on-root', false], + ]); + }); + + describe('when changed', () => { + beforeEach(() => { + updateElementsVisibility.mockClear(); + + wrapper.setProps({ path: '/test' }); + }); + + it('hides root elements', () => { + expect(updateElementsVisibility.mock.calls).toEqual([ + ['.js-show-on-root', false], + ['.js-hide-on-root', true], + ]); + }); + }); + }); + + describe('when non-root path', () => { + beforeEach(() => { + factory('/test'); + }); + + it('hides root elements', () => { + expect(updateElementsVisibility.mock.calls).toEqual([ + ['.js-show-on-root', false], + ['.js-hide-on-root', true], + ]); + }); + }); +}); diff --git a/spec/frontend/repository/utils/commit_spec.js b/spec/frontend/repository/utils/commit_spec.js new file mode 100644 index 00000000000..2d75358106c --- /dev/null +++ b/spec/frontend/repository/utils/commit_spec.js @@ -0,0 +1,30 @@ +import { normalizeData } from '~/repository/utils/commit'; + +const mockData = [ + { + commit: { + id: '123', + message: 'testing message', + committed_date: '2019-01-01', + }, + commit_path: `https://test.com`, + file_name: 'index.js', + type: 'blob', + }, +]; + +describe('normalizeData', () => { + it('normalizes data into LogTreeCommit object', () => { + expect(normalizeData(mockData)).toEqual([ + { + sha: '123', + message: 'testing message', + committedDate: '2019-01-01', + commitPath: 'https://test.com', + fileName: 'index.js', + type: 'blob', + __typename: 'LogTreeCommit', + }, + ]); + }); +}); diff --git a/spec/frontend/repository/utils/dom_spec.js b/spec/frontend/repository/utils/dom_spec.js new file mode 100644 index 00000000000..678d444904d --- /dev/null +++ b/spec/frontend/repository/utils/dom_spec.js @@ -0,0 +1,20 @@ +import { setHTMLFixture } from '../../helpers/fixtures'; +import { updateElementsVisibility } from '~/repository/utils/dom'; + +describe('updateElementsVisibility', () => { + it('adds hidden class', () => { + setHTMLFixture('<div class="js-test"></div>'); + + updateElementsVisibility('.js-test', false); + + expect(document.querySelector('.js-test').classList).toContain('hidden'); + }); + + it('removes hidden class', () => { + setHTMLFixture('<div class="hidden js-test"></div>'); + + updateElementsVisibility('.js-test', true); + + expect(document.querySelector('.js-test').classList).not.toContain('hidden'); + }); +}); diff --git a/spec/frontend/repository/utils/readme_spec.js b/spec/frontend/repository/utils/readme_spec.js new file mode 100644 index 00000000000..6b7876c8947 --- /dev/null +++ b/spec/frontend/repository/utils/readme_spec.js @@ -0,0 +1,33 @@ +import { readmeFile } from '~/repository/utils/readme'; + +describe('readmeFile', () => { + describe('markdown files', () => { + it('returns markdown file', () => { + expect(readmeFile([{ name: 'README' }, { name: 'README.md' }])).toEqual({ + name: 'README.md', + }); + + expect(readmeFile([{ name: 'README' }, { name: 'index.md' }])).toEqual({ + name: 'index.md', + }); + }); + }); + + describe('plain files', () => { + it('returns plain file', () => { + expect(readmeFile([{ name: 'README' }, { name: 'TEST.md' }])).toEqual({ + name: 'README', + }); + + expect(readmeFile([{ name: 'readme' }, { name: 'TEST.md' }])).toEqual({ + name: 'readme', + }); + }); + }); + + describe('non-previewable file', () => { + it('returns undefined', () => { + expect(readmeFile([{ name: 'index.js' }, { name: 'TEST.md' }])).toBe(undefined); + }); + }); +}); diff --git a/spec/frontend/repository/utils/title_spec.js b/spec/frontend/repository/utils/title_spec.js index c4879716fd7..63035933424 100644 --- a/spec/frontend/repository/utils/title_spec.js +++ b/spec/frontend/repository/utils/title_spec.js @@ -8,8 +8,8 @@ describe('setTitle', () => { ${'app/assets'} | ${'app/assets'} ${'app/assets/javascripts'} | ${'app/assets/javascripts'} `('sets document title as $title for $path', ({ path, title }) => { - setTitle(path, 'master', 'GitLab'); + setTitle(path, 'master', 'GitLab Org / GitLab'); - expect(document.title).toEqual(`${title} · master · GitLab`); + expect(document.title).toEqual(`${title} · master · GitLab Org / GitLab · GitLab`); }); }); diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js new file mode 100644 index 00000000000..82b6c445d96 --- /dev/null +++ b/spec/frontend/sentry/index_spec.js @@ -0,0 +1,44 @@ +import SentryConfig from '~/sentry/sentry_config'; +import index from '~/sentry/index'; + +describe('SentryConfig options', () => { + const dsn = 'https://123@sentry.gitlab.test/123'; + const currentUserId = 'currentUserId'; + const gitlabUrl = 'gitlabUrl'; + const environment = 'test'; + const revision = 'revision'; + let indexReturnValue; + + beforeEach(() => { + window.gon = { + sentry_dsn: dsn, + sentry_environment: environment, + current_user_id: currentUserId, + gitlab_url: gitlabUrl, + revision, + }; + + process.env.HEAD_COMMIT_SHA = revision; + + jest.spyOn(SentryConfig, 'init').mockImplementation(); + + indexReturnValue = index(); + }); + + it('should init with .sentryDsn, .currentUserId, .whitelistUrls and environment', () => { + expect(SentryConfig.init).toHaveBeenCalledWith({ + dsn, + currentUserId, + whitelistUrls: [gitlabUrl, 'webpack-internal://'], + environment, + release: revision, + tags: { + revision, + }, + }); + }); + + it('should return SentryConfig', () => { + expect(indexReturnValue).toBe(SentryConfig); + }); +}); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js new file mode 100644 index 00000000000..62b8bbd50a2 --- /dev/null +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -0,0 +1,214 @@ +import * as Sentry from '@sentry/browser'; +import SentryConfig from '~/sentry/sentry_config'; + +describe('SentryConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = SentryConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); + + expect(areStrings).toBe(true); + }); + }); + + describe('BLACKLIST_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = SentryConfig.BLACKLIST_URLS.every(url => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof SentryConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + + describe('init', () => { + const options = { + currentUserId: 1, + }; + + beforeEach(() => { + jest.spyOn(SentryConfig, 'configure'); + jest.spyOn(SentryConfig, 'bindSentryErrors'); + jest.spyOn(SentryConfig, 'setUser'); + + SentryConfig.init(options); + }); + + it('should set the options property', () => { + expect(SentryConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(SentryConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(SentryConfig.bindSentryErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(SentryConfig.setUser).toHaveBeenCalled(); + }); + + it('should not call setUser if there is no current user ID', () => { + jest.clearAllMocks(); + + options.currentUserId = undefined; + + SentryConfig.init(options); + + expect(SentryConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + const sentryConfig = {}; + const options = { + dsn: 'https://123@sentry.gitlab.test/123', + whitelistUrls: ['//gitlabUrl', 'webpack-internal://'], + environment: 'test', + release: 'revision', + tags: { + revision: 'revision', + }, + }; + + beforeEach(() => { + jest.spyOn(Sentry, 'init').mockImplementation(); + + sentryConfig.options = options; + sentryConfig.IGNORE_ERRORS = 'ignore_errors'; + sentryConfig.BLACKLIST_URLS = 'blacklist_urls'; + + SentryConfig.configure.call(sentryConfig); + }); + + it('should call Sentry.init', () => { + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: options.dsn, + release: options.release, + tags: options.tags, + sampleRate: 0.95, + whitelistUrls: options.whitelistUrls, + environment: 'test', + ignoreErrors: sentryConfig.IGNORE_ERRORS, + blacklistUrls: sentryConfig.BLACKLIST_URLS, + }); + }); + + it('should set environment from options', () => { + sentryConfig.options.environment = 'development'; + + SentryConfig.configure.call(sentryConfig); + + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: options.dsn, + release: options.release, + tags: options.tags, + sampleRate: 0.95, + whitelistUrls: options.whitelistUrls, + environment: 'development', + ignoreErrors: sentryConfig.IGNORE_ERRORS, + blacklistUrls: sentryConfig.BLACKLIST_URLS, + }); + }); + }); + + describe('setUser', () => { + let sentryConfig; + + beforeEach(() => { + sentryConfig = { options: { currentUserId: 1 } }; + jest.spyOn(Sentry, 'setUser'); + + SentryConfig.setUser.call(sentryConfig); + }); + + it('should call .setUser', () => { + expect(Sentry.setUser).toHaveBeenCalledWith({ + id: sentryConfig.options.currentUserId, + }); + }); + }); + + describe('handleSentryErrors', () => { + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'Unknown response text', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + jest.spyOn(Sentry, 'captureMessage'); + + SentryConfig.handleSentryErrors(event, req, config, err); + }); + + it('should call Sentry.captureMessage', () => { + expect(Sentry.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + jest.clearAllMocks(); + + SentryConfig.handleSentryErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Sentry.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: req.statusText, + event, + }, + }); + }); + }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + jest.clearAllMocks(); + + SentryConfig.handleSentryErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Sentry.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js index 452d4cd07cc..d0d1af56872 100644 --- a/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_avatar_link_spec.js @@ -24,6 +24,7 @@ describe('AssigneeAvatarLink component', () => { }; wrapper = shallowMount(AssigneeAvatarLink, { + attachToDocument: true, propsData, sync: false, }); diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js index ff0c8d181b5..c88ae196875 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js @@ -16,6 +16,7 @@ describe('CollapsedAssigneeList component', () => { }; wrapper = shallowMount(CollapsedAssigneeList, { + attachToDocument: true, propsData, sync: false, }); diff --git a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js index 6398351834c..1de21f30d21 100644 --- a/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/uncollapsed_assignee_list_spec.js @@ -18,6 +18,7 @@ describe('UncollapsedAssigneeList component', () => { }; wrapper = mount(UncollapsedAssigneeList, { + attachToDocument: true, sync: false, propsData, }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap new file mode 100644 index 00000000000..95296de5a5d --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SplitButton renders actionItems 1`] = ` +<gldropdown-stub + menu-class="dropdown-menu-selectable " + split="true" + text="professor" +> + <gldropdownitem-stub + active="true" + active-class="is-active" + > + <strong> + professor + </strong> + + <div> + very symphonic + </div> + </gldropdownitem-stub> + + <gldropdowndivider-stub /> + <gldropdownitem-stub + active-class="is-active" + > + <strong> + captain + </strong> + + <div> + warp drive + </div> + </gldropdownitem-stub> + + <!----> +</gldropdown-stub> +`; diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js new file mode 100644 index 00000000000..77d8e00cf00 --- /dev/null +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -0,0 +1,227 @@ +import { shallowMount } from '@vue/test-utils'; +import CommitComponent from '~/vue_shared/components/commit.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +describe('Commit component', () => { + let props; + let wrapper; + + const findUserAvatar = () => wrapper.find(UserAvatarLink); + + const createComponent = propsData => { + wrapper = shallowMount(CommitComponent, { + propsData, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render a fork icon if it does not represent a tag', () => { + createComponent({ + tag: false, + commitRef: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commitUrl: + 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', + shortSha: 'b7836edd', + title: 'Commit message', + author: { + avatar_url: 'https://gitlab.com/uploads/-/system/user/avatar/300478/avatar.png', + web_url: 'https://gitlab.com/jschatz1', + path: '/jschatz1', + username: 'jschatz1', + }, + }); + + expect( + wrapper + .find('.icon-container') + .find(Icon) + .exists(), + ).toBe(true); + }); + + describe('Given all the props', () => { + beforeEach(() => { + props = { + tag: true, + commitRef: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commitUrl: + 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', + shortSha: 'b7836edd', + title: 'Commit message', + author: { + avatar_url: 'https://gitlab.com/uploads/-/system/user/avatar/300478/avatar.png', + web_url: 'https://gitlab.com/jschatz1', + path: '/jschatz1', + username: 'jschatz1', + }, + }; + createComponent(props); + }); + + it('should render a tag icon if it represents a tag', () => { + expect(wrapper.find('icon-stub[name="tag"]').exists()).toBe(true); + }); + + it('should render a link to the ref url', () => { + expect(wrapper.find('.ref-name').attributes('href')).toBe(props.commitRef.ref_url); + }); + + it('should render the ref name', () => { + expect(wrapper.find('.ref-name').text()).toContain(props.commitRef.name); + }); + + it('should render the commit short sha with a link to the commit url', () => { + expect(wrapper.find('.commit-sha').attributes('href')).toEqual(props.commitUrl); + + expect(wrapper.find('.commit-sha').text()).toContain(props.shortSha); + }); + + it('should render icon for commit', () => { + expect(wrapper.find('icon-stub[name="commit"]').exists()).toBe(true); + }); + + describe('Given commit title and author props', () => { + it('should render a link to the author profile', () => { + const userAvatar = findUserAvatar(); + + expect(userAvatar.props('linkHref')).toBe(props.author.path); + }); + + it('Should render the author avatar with title and alt attributes', () => { + const userAvatar = findUserAvatar(); + + expect(userAvatar.exists()).toBe(true); + + expect(userAvatar.props('imgAlt')).toBe(`${props.author.username}'s avatar`); + }); + }); + + it('should render the commit title', () => { + expect(wrapper.find('.commit-row-message').attributes('href')).toEqual(props.commitUrl); + + expect(wrapper.find('.commit-row-message').text()).toContain(props.title); + }); + }); + + describe('When commit title is not provided', () => { + it('should render default message', () => { + props = { + tag: false, + commitRef: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commitUrl: + 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', + shortSha: 'b7836edd', + title: null, + author: {}, + }; + + createComponent(props); + + expect(wrapper.find('.commit-title span').text()).toContain( + "Can't find HEAD commit for this branch", + ); + }); + }); + + describe('When commit ref is provided, but merge ref is not', () => { + it('should render the commit ref', () => { + props = { + tag: false, + commitRef: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commitUrl: + 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', + shortSha: 'b7836edd', + title: null, + author: {}, + }; + + createComponent(props); + const refEl = wrapper.find('.ref-name'); + + expect(refEl.text()).toContain('master'); + + expect(refEl.attributes('href')).toBe(props.commitRef.ref_url); + + expect(refEl.attributes('data-original-title')).toBe(props.commitRef.name); + + expect(wrapper.find('icon-stub[name="branch"]').exists()).toBe(true); + }); + }); + + describe('When both commit and merge ref are provided', () => { + it('should render the merge ref', () => { + props = { + tag: false, + commitRef: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commitUrl: + 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', + mergeRequestRef: { + iid: 1234, + path: 'https://example.com/path/to/mr', + title: 'Test MR', + }, + shortSha: 'b7836edd', + title: null, + author: {}, + }; + + createComponent(props); + const refEl = wrapper.find('.ref-name'); + + expect(refEl.text()).toContain('1234'); + + expect(refEl.attributes('href')).toBe(props.mergeRequestRef.path); + + expect(refEl.attributes('data-original-title')).toBe(props.mergeRequestRef.title); + + expect(wrapper.find('icon-stub[name="git-merge"]').exists()).toBe(true); + }); + }); + + describe('When showRefInfo === false', () => { + it('should not render any ref info', () => { + props = { + tag: false, + commitRef: { + name: 'master', + ref_url: 'http://localhost/namespace2/gitlabhq/tree/master', + }, + commitUrl: + 'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067', + mergeRequestRef: { + iid: 1234, + path: '/path/to/mr', + title: 'Test MR', + }, + shortSha: 'b7836edd', + title: null, + author: {}, + showRefInfo: false, + }; + + createComponent(props); + + expect(wrapper.find('.ref-name').exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js new file mode 100644 index 00000000000..3ad8f3aec7c --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/image_viewer_spec.js @@ -0,0 +1,45 @@ +import { shallowMount } from '@vue/test-utils'; + +import ImageViewer from '~/vue_shared/components/content_viewer/viewers/image_viewer.vue'; +import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; + +describe('Image Viewer', () => { + const requiredProps = { + path: GREEN_BOX_IMAGE_URL, + renderInfo: true, + }; + let wrapper; + let imageInfo; + + function createElement({ props, includeRequired = true } = {}) { + const data = includeRequired ? { ...requiredProps, ...props } : { ...props }; + + wrapper = shallowMount(ImageViewer, { + propsData: data, + }); + imageInfo = wrapper.find('.image-info'); + } + + describe('file sizes', () => { + it('should show the humanized file size when `renderInfo` is true and there is size info', () => { + createElement({ props: { fileSize: 1024 } }); + + expect(imageInfo.text()).toContain('1.00 KiB'); + }); + + it('should not show the humanized file size when `renderInfo` is true and there is no size', () => { + const FILESIZE_RE = /\d+(\.\d+)?\s*([KMGTP]i)*B/; + + createElement({ props: { fileSize: 0 } }); + + // It shouldn't show any filesize info + expect(imageInfo.text()).not.toMatch(FILESIZE_RE); + }); + + it('should not show any image information when `renderInfo` is false', () => { + createElement({ props: { renderInfo: false } }); + + expect(imageInfo.exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index d1de98f4a15..9e6b5286899 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -1,114 +1,129 @@ -import Vue from 'vue'; - +import { shallowMount } from '@vue/test-utils'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; import { mockAssigneesList } from '../../../../javascripts/boards/mock_data'; -const createComponent = (assignees = mockAssigneesList, cssClass = '') => { - const Component = Vue.extend(IssueAssignees); - - return mountComponent(Component, { - assignees, - cssClass, - }); -}; +const TEST_CSS_CLASSES = 'test-classes'; +const TEST_MAX_VISIBLE = 4; +const TEST_ICON_SIZE = 16; describe('IssueAssigneesComponent', () => { + let wrapper; let vm; - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('data', () => { - it('returns default data props', () => { - expect(vm.maxVisibleAssignees).toBe(2); - expect(vm.maxAssigneeAvatars).toBe(3); - expect(vm.maxAssignees).toBe(99); + const factory = props => { + wrapper = shallowMount(IssueAssignees, { + propsData: { + assignees: mockAssigneesList, + ...props, + }, + sync: false, }); + vm = wrapper.vm; // eslint-disable-line + }; + + const findTooltipText = () => wrapper.find('.js-assignee-tooltip').text(); + const findAvatars = () => wrapper.findAll(UserAvatarLink); + const findOverflowCounter = () => wrapper.find('.avatar-counter'); + + it('returns default data props', () => { + factory({ assignees: mockAssigneesList }); + expect(vm.iconSize).toBe(24); + expect(vm.maxVisible).toBe(3); + expect(vm.maxAssignees).toBe(99); }); - describe('computed', () => { - describe('countOverLimit', () => { - it('should return difference between assignees count and maxVisibleAssignees', () => { - expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees); - }); - }); - - describe('assigneesToShow', () => { - it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => { - expect(vm.assigneesToShow.length).toBe(2); - }); - - it('should return all assignees as it is when count less than maxAssigneeAvatars', () => { - vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees - - expect(vm.assigneesToShow.length).toBe(3); - }); - }); - - describe('assigneesCounterTooltip', () => { - it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => { - expect(vm.assigneesCounterTooltip).toBe('3 more assignees'); - }); - }); - - describe('shouldRenderAssigneesCounter', () => { - it('should return `false` when assignees count less than maxAssigneeAvatars', () => { - vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees - - expect(vm.shouldRenderAssigneesCounter).toBe(false); - }); - - it('should return `true` when assignees count more than maxAssigneeAvatars', () => { - expect(vm.shouldRenderAssigneesCounter).toBe(true); + describe.each` + numAssignees | maxVisible | expectedShown | expectedHidden + ${0} | ${3} | ${0} | ${''} + ${1} | ${3} | ${1} | ${''} + ${2} | ${3} | ${2} | ${''} + ${3} | ${3} | ${3} | ${''} + ${4} | ${3} | ${2} | ${'+2'} + ${5} | ${2} | ${1} | ${'+4'} + ${1000} | ${5} | ${4} | ${'99+'} + `( + 'with assignees ($numAssignees) and maxVisible ($maxVisible)', + ({ numAssignees, maxVisible, expectedShown, expectedHidden }) => { + beforeEach(() => { + factory({ assignees: Array(numAssignees).fill({}), maxVisible }); }); - }); - describe('assigneeCounterLabel', () => { - it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => { - expect(vm.assigneeCounterLabel).toBe('+3'); + if (expectedShown) { + it('shows assignee avatars', () => { + expect(findAvatars().length).toEqual(expectedShown); + }); + } else { + it('does not show assignee avatars', () => { + expect(findAvatars().length).toEqual(0); + }); + } + + if (expectedHidden) { + it('shows overflow counter', () => { + const hiddenCount = numAssignees - expectedShown; + + expect(findOverflowCounter().exists()).toBe(true); + expect(findOverflowCounter().text()).toEqual(expectedHidden.toString()); + expect(findOverflowCounter().attributes('data-original-title')).toEqual( + `${hiddenCount} more assignees`, + ); + }); + } else { + it('does not show overflow counter', () => { + expect(findOverflowCounter().exists()).toBe(false); + }); + } + }, + ); + + describe('when mounted', () => { + beforeEach(() => { + factory({ + imgCssClasses: TEST_CSS_CLASSES, + maxVisible: TEST_MAX_VISIBLE, + iconSize: TEST_ICON_SIZE, }); }); - }); - describe('methods', () => { - describe('avatarUrlTitle', () => { - it('returns string containing alt text for assignee avatar', () => { - expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); - }); + it('computes alt text for assignee avatar', () => { + expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); }); - }); - describe('template', () => { it('renders component root element with class `issue-assignees`', () => { - expect(vm.$el.classList.contains('issue-assignees')).toBe(true); + expect(wrapper.element.classList.contains('issue-assignees')).toBe(true); }); - it('renders assignee avatars', () => { - expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2); + it('renders assignee', () => { + const data = findAvatars().wrappers.map(x => ({ + ...x.props(), + })); + + const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map(x => + expect.objectContaining({ + linkHref: x.web_url, + imgAlt: `Avatar for ${x.name}`, + imgCssClasses: TEST_CSS_CLASSES, + imgSrc: x.avatar_url, + imgSize: TEST_ICON_SIZE, + }), + ); + + expect(data).toEqual(expected); }); - it('renders assignee tooltips', () => { - const tooltipText = vm.$el - .querySelectorAll('.user-avatar-link')[0] - .querySelector('.js-assignee-tooltip').innerText; - - expect(tooltipText).toContain('Assignee'); - expect(tooltipText).toContain('Terrell Graham'); - expect(tooltipText).toContain('@monserrate.gleichner'); - }); + describe('assignee tooltips', () => { + it('renders "Assignee" header', () => { + expect(findTooltipText()).toContain('Assignee'); + }); - it('renders additional assignees count', () => { - const avatarCounterEl = vm.$el.querySelector('.avatar-counter'); + it('renders assignee name', () => { + expect(findTooltipText()).toContain('Terrell Graham'); + }); - expect(avatarCounterEl.innerText.trim()).toBe('+3'); - expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees'); + it('renders assignee @username', () => { + expect(findTooltipText()).toContain('@monserrate.gleichner'); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index eafff7f681e..45f131194ca 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import issuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import createStore from '~/notes/stores'; -import { userDataMock } from '../../../../javascripts/notes/mock_data'; +import { userDataMock } from '../../../notes/mock_data'; describe('issue placeholder system note component', () => { let store; diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index a65e3eb294a..c2e8359f78d 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -57,7 +57,7 @@ describe('system note component', () => { // we need to strip them because they break layout of commit lists in system notes: // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png it('removes wrapping paragraph from note HTML', () => { - expect(vm.$el.querySelector('.system-note-message').innerHTML).toEqual('<span>closed</span>'); + expect(vm.$el.querySelector('.system-note-message').innerHTML).toContain('<span>closed</span>'); }); it('should initMRPopovers onMount', () => { diff --git a/spec/frontend/vue_shared/components/slot_switch_spec.js b/spec/frontend/vue_shared/components/slot_switch_spec.js new file mode 100644 index 00000000000..cff955c05b2 --- /dev/null +++ b/spec/frontend/vue_shared/components/slot_switch_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; + +import SlotSwitch from '~/vue_shared/components/slot_switch'; + +describe('SlotSwitch', () => { + const slots = { + first: '<a>AGP</a>', + second: '<p>PCI</p>', + }; + + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(SlotSwitch, { + propsData, + slots, + sync: false, + }); + }; + + const getChildrenHtml = () => wrapper.findAll('* *').wrappers.map(c => c.html()); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + }); + + it('throws an error if activeSlotNames is missing', () => { + expect(createComponent).toThrow('[Vue warn]: Missing required prop: "activeSlotNames"'); + }); + + it('renders no slots if activeSlotNames is empty', () => { + createComponent({ + activeSlotNames: [], + }); + + expect(getChildrenHtml().length).toBe(0); + }); + + it('renders one slot if activeSlotNames contains single slot name', () => { + createComponent({ + activeSlotNames: ['first'], + }); + + expect(getChildrenHtml()).toEqual([slots.first]); + }); + + it('renders multiple slots if activeSlotNames contains multiple slot names', () => { + createComponent({ + activeSlotNames: Object.keys(slots), + }); + + expect(getChildrenHtml()).toEqual(Object.values(slots)); + }); +}); diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js new file mode 100644 index 00000000000..520abb02cf7 --- /dev/null +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -0,0 +1,104 @@ +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import SplitButton from '~/vue_shared/components/split_button.vue'; + +const mockActionItems = [ + { + eventName: 'concert', + title: 'professor', + description: 'very symphonic', + }, + { + eventName: 'apocalypse', + title: 'captain', + description: 'warp drive', + }, +]; + +describe('SplitButton', () => { + let wrapper; + + const createComponent = propsData => { + wrapper = shallowMount(SplitButton, { + propsData, + sync: false, + }); + }; + + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItem = (index = 0) => + findDropdown() + .findAll(GlDropdownItem) + .at(index); + const selectItem = index => { + findDropdownItem(index).vm.$emit('click'); + + return wrapper.vm.$nextTick(); + }; + const clickToggleButton = () => { + findDropdown().vm.$emit('click'); + + return wrapper.vm.$nextTick(); + }; + + it('fails for empty actionItems', () => { + const actionItems = []; + expect(() => createComponent({ actionItems })).toThrow(); + }); + + it('fails for single actionItems', () => { + const actionItems = [mockActionItems[0]]; + expect(() => createComponent({ actionItems })).toThrow(); + }); + + it('renders actionItems', () => { + createComponent({ actionItems: mockActionItems }); + + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('toggle button text', () => { + beforeEach(() => { + createComponent({ actionItems: mockActionItems }); + }); + + it('defaults to first actionItems title', () => { + expect(findDropdown().props().text).toBe(mockActionItems[0].title); + }); + + it('changes to selected actionItems title', () => + selectItem(1).then(() => { + expect(findDropdown().props().text).toBe(mockActionItems[1].title); + })); + }); + + describe('emitted event', () => { + let eventHandler; + + beforeEach(() => { + createComponent({ actionItems: mockActionItems }); + }); + + const addEventHandler = ({ eventName }) => { + eventHandler = jest.fn(); + wrapper.vm.$once(eventName, () => eventHandler()); + }; + + it('defaults to first actionItems event', () => { + addEventHandler(mockActionItems[0]); + + return clickToggleButton().then(() => { + expect(eventHandler).toHaveBeenCalled(); + }); + }); + + it('changes to selected actionItems event', () => + selectItem(1) + .then(() => addEventHandler(mockActionItems[1])) + .then(clickToggleButton) + .then(() => { + expect(eventHandler).toHaveBeenCalled(); + })); + }); +}); diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js new file mode 100644 index 00000000000..0a9ff36b2fb --- /dev/null +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -0,0 +1,335 @@ +import { shallowMount } from '@vue/test-utils'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; + +describe('Pagination component', () => { + let wrapper; + let spy; + + const mountComponent = props => { + wrapper = shallowMount(TablePagination, { + sync: false, + propsData: props, + }); + }; + + const findFirstButtonLink = () => wrapper.find('.js-first-button .page-link'); + const findPreviousButton = () => wrapper.find('.js-previous-button'); + const findPreviousButtonLink = () => wrapper.find('.js-previous-button .page-link'); + const findNextButton = () => wrapper.find('.js-next-button'); + const findNextButtonLink = () => wrapper.find('.js-next-button .page-link'); + const findLastButtonLink = () => wrapper.find('.js-last-button .page-link'); + const findPages = () => wrapper.findAll('.page'); + const findSeparator = () => wrapper.find('.separator'); + + beforeEach(() => { + spy = jest.fn(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('render', () => { + it('should not render anything', () => { + mountComponent({ + pageInfo: { + nextPage: NaN, + page: 1, + perPage: 20, + previousPage: NaN, + total: 15, + totalPages: 1, + }, + change: spy, + }); + + expect(wrapper.isEmpty()).toBe(true); + }); + + describe('prev button', () => { + it('should be disabled and non clickable', () => { + mountComponent({ + pageInfo: { + nextPage: 2, + page: 1, + perPage: 20, + previousPage: NaN, + total: 84, + totalPages: 5, + }, + change: spy, + }); + + expect(findPreviousButton().classes()).toContain('disabled'); + findPreviousButtonLink().trigger('click'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should be disabled and non clickable when total and totalPages are NaN', () => { + mountComponent({ + pageInfo: { + nextPage: 2, + page: 1, + perPage: 20, + previousPage: NaN, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + expect(findPreviousButton().classes()).toContain('disabled'); + findPreviousButtonLink().trigger('click'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should be enabled and clickable', () => { + mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: 84, + totalPages: 5, + }, + change: spy, + }); + findPreviousButtonLink().trigger('click'); + expect(spy).toHaveBeenCalledWith(1); + }); + + it('should be enabled and clickable when total and totalPages are NaN', () => { + mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + findPreviousButtonLink().trigger('click'); + expect(spy).toHaveBeenCalledWith(1); + }); + }); + + describe('first button', () => { + it('should call the change callback with the first page', () => { + mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: 84, + totalPages: 5, + }, + change: spy, + }); + const button = findFirstButtonLink(); + expect(button.text().trim()).toEqual('« First'); + button.trigger('click'); + expect(spy).toHaveBeenCalledWith(1); + }); + + it('should call the change callback with the first page when total and totalPages are NaN', () => { + mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + const button = findFirstButtonLink(); + expect(button.text().trim()).toEqual('« First'); + button.trigger('click'); + expect(spy).toHaveBeenCalledWith(1); + }); + }); + + describe('last button', () => { + it('should call the change callback with the last page', () => { + mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: 84, + totalPages: 5, + }, + change: spy, + }); + const button = findLastButtonLink(); + expect(button.text().trim()).toEqual('Last »'); + button.trigger('click'); + expect(spy).toHaveBeenCalledWith(5); + }); + + it('should not render', () => { + mountComponent({ + pageInfo: { + nextPage: 3, + page: 2, + perPage: 20, + previousPage: 1, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + expect(findLastButtonLink().exists()).toBe(false); + }); + }); + + describe('next button', () => { + it('should be disabled and non clickable', () => { + mountComponent({ + pageInfo: { + nextPage: NaN, + page: 5, + perPage: 20, + previousPage: 4, + total: 84, + totalPages: 5, + }, + change: spy, + }); + expect( + findNextButton() + .text() + .trim(), + ).toEqual('Next ›'); + findNextButtonLink().trigger('click'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should be disabled and non clickable when total and totalPages are NaN', () => { + mountComponent({ + pageInfo: { + nextPage: NaN, + page: 5, + perPage: 20, + previousPage: 4, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + expect( + findNextButton() + .text() + .trim(), + ).toEqual('Next ›'); + findNextButtonLink().trigger('click'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should be enabled and clickable', () => { + mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: 84, + totalPages: 5, + }, + change: spy, + }); + findNextButtonLink().trigger('click'); + expect(spy).toHaveBeenCalledWith(4); + }); + + it('should be enabled and clickable when total and totalPages are NaN', () => { + mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + findNextButtonLink().trigger('click'); + expect(spy).toHaveBeenCalledWith(4); + }); + }); + + describe('numbered buttons', () => { + it('should render 5 pages', () => { + mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: 84, + totalPages: 5, + }, + change: spy, + }); + expect(findPages().length).toEqual(5); + }); + + it('should not render any page', () => { + mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + expect(findPages().length).toEqual(0); + }); + }); + + describe('spread operator', () => { + it('should render', () => { + mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: 84, + totalPages: 10, + }, + change: spy, + }); + expect( + findSeparator() + .text() + .trim(), + ).toEqual('...'); + }); + + it('should not render', () => { + mountComponent({ + pageInfo: { + nextPage: 4, + page: 3, + perPage: 20, + previousPage: 2, + total: NaN, + totalPages: NaN, + }, + change: spy, + }); + expect(findSeparator().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js new file mode 100644 index 00000000000..2f87359a4a6 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import { placeholderImage } from '~/lazy_loader'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import defaultAvatarUrl from 'images/no_avatar.png'; + +jest.mock('images/no_avatar.png', () => 'default-avatar-url'); + +const DEFAULT_PROPS = { + size: 99, + imgSrc: 'myavatarurl.com', + imgAlt: 'mydisplayname', + cssClasses: 'myextraavatarclass', + tooltipText: 'tooltip text', + tooltipPlacement: 'bottom', +}; + +describe('User Avatar Image Component', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Initialization', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...DEFAULT_PROPS, + }, + sync: false, + }); + }); + + it('should have <img> as a child element', () => { + const imageElement = wrapper.find('img'); + + expect(imageElement.exists()).toBe(true); + expect(imageElement.attributes('src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + expect(imageElement.attributes('alt')).toBe(DEFAULT_PROPS.imgAlt); + }); + + it('should properly render img css', () => { + const classes = wrapper.find('img').classes(); + expect(classes).toEqual(expect.arrayContaining(['avatar', 's99', DEFAULT_PROPS.cssClasses])); + expect(classes).not.toContain('lazy'); + }); + }); + + describe('Initialization when lazy', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { + ...DEFAULT_PROPS, + lazy: true, + }, + sync: false, + }); + }); + + it('should add lazy attributes', () => { + const imageElement = wrapper.find('img'); + + expect(imageElement.classes()).toContain('lazy'); + expect(imageElement.attributes('src')).toBe(placeholderImage); + expect(imageElement.attributes('data-src')).toBe(`${DEFAULT_PROPS.imgSrc}?width=99`); + }); + }); + + describe('Initialization without src', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { sync: false }); + }); + + it('should have default avatar image', () => { + const imageElement = wrapper.find('img'); + + expect(imageElement.attributes('src')).toBe(`${defaultAvatarUrl}?width=20`); + }); + }); + + describe('dynamic tooltip content', () => { + const props = DEFAULT_PROPS; + const slots = { + default: ['Action!'], + }; + + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { propsData: { props }, slots, sync: false }); + }); + + it('renders the tooltip slot', () => { + expect(wrapper.find('.js-user-avatar-image-toolip').exists()).toBe(true); + }); + + it('renders the tooltip content', () => { + expect(wrapper.find('.js-user-avatar-image-toolip').text()).toContain(slots.default[0]); + }); + + it('does not render tooltip data attributes for on avatar image', () => { + const avatarImg = wrapper.find('img'); + + expect(avatarImg.attributes('data-original-title')).toBeFalsy(); + expect(avatarImg.attributes('data-placement')).not.toBeDefined(); + expect(avatarImg.attributes('data-container')).not.toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js new file mode 100644 index 00000000000..fc2eb6329b0 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -0,0 +1,186 @@ +import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; +import { mount } from '@vue/test-utils'; + +const DEFAULT_PROPS = { + loaded: true, + user: { + username: 'root', + name: 'Administrator', + location: 'Vienna', + bio: null, + organization: null, + status: null, + }, +}; + +describe('User Popover Component', () => { + const fixtureTemplate = 'merge_requests/diff_comment.html'; + preloadFixtures(fixtureTemplate); + + let wrapper; + + beforeEach(() => { + loadFixtures(fixtureTemplate); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('Empty', () => { + beforeEach(() => { + wrapper = mount(UserPopover, { + propsData: { + target: document.querySelector('.js-user-link'), + user: { + name: null, + username: null, + location: null, + bio: null, + organization: null, + status: null, + }, + }, + sync: false, + }); + }); + + it('should return skeleton loaders', () => { + expect(wrapper.findAll('.animation-container').length).toBe(4); + }); + }); + + describe('basic data', () => { + it('should show basic fields', () => { + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.text()).toContain(DEFAULT_PROPS.user.name); + expect(wrapper.text()).toContain(DEFAULT_PROPS.user.username); + expect(wrapper.text()).toContain(DEFAULT_PROPS.user.location); + }); + + it('shows icon for location', () => { + const iconEl = wrapper.find('.js-location svg'); + + expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('location'); + }); + }); + + describe('job data', () => { + it('should show only bio if no organization is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + + wrapper = mount(UserPopover, { + propsData: { + ...testProps, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.text()).toContain('Engineer'); + }); + + it('should show only organization if no bio is available', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.organization = 'GitLab'; + + wrapper = mount(UserPopover, { + propsData: { + ...testProps, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.text()).toContain('GitLab'); + }); + + it('should display bio and organization in separate lines', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Engineer'; + testProps.user.organization = 'GitLab'; + + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.find('.js-bio').text()).toContain('Engineer'); + expect(wrapper.find('.js-organization').text()).toContain('GitLab'); + }); + + it('should not encode special characters in bio and organization', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.bio = 'Manager & Team Lead'; + testProps.user.organization = 'Me & my <funky> Company'; + + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.find('.js-bio').text()).toContain('Manager & Team Lead'); + expect(wrapper.find('.js-organization').text()).toContain('Me & my <funky> Company'); + }); + + it('shows icon for bio', () => { + const iconEl = wrapper.find('.js-bio svg'); + + expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('profile'); + }); + + it('shows icon for organization', () => { + const iconEl = wrapper.find('.js-organization svg'); + + expect(iconEl.find('use').element.getAttribute('xlink:href')).toContain('work'); + }); + }); + + describe('status data', () => { + it('should show only message', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { message_html: 'Hello World' }; + + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + }, + sync: false, + }); + + expect(wrapper.text()).toContain('Hello World'); + }); + + it('should show message and emoji', () => { + const testProps = Object.assign({}, DEFAULT_PROPS); + testProps.user.status = { emoji: 'basketball_player', message_html: 'Hello World' }; + + wrapper = mount(UserPopover, { + propsData: { + ...DEFAULT_PROPS, + target: document.querySelector('.js-user-link'), + status: { emoji: 'basketball_player', message_html: 'Hello World' }, + }, + sync: false, + }); + + expect(wrapper.text()).toContain('Hello World'); + expect(wrapper.html()).toContain('<gl-emoji data-name="basketball_player"'); + }); + }); +}); |