diff options
Diffstat (limited to 'spec/frontend')
46 files changed, 5931 insertions, 0 deletions
diff --git a/spec/frontend/behaviors/bind_in_out_spec.js b/spec/frontend/behaviors/bind_in_out_spec.js new file mode 100644 index 00000000000..923b6d372dd --- /dev/null +++ b/spec/frontend/behaviors/bind_in_out_spec.js @@ -0,0 +1,204 @@ +import BindInOut from '~/behaviors/bind_in_out'; +import ClassSpecHelper from '../helpers/class_spec_helper'; + +describe('BindInOut', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('constructor', () => { + beforeEach(() => { + testContext.in = {}; + testContext.out = {}; + + testContext.bindInOut = new BindInOut(testContext.in, testContext.out); + }); + + it('should set .in', () => { + expect(testContext.bindInOut.in).toBe(testContext.in); + }); + + it('should set .out', () => { + expect(testContext.bindInOut.out).toBe(testContext.out); + }); + + it('should set .eventWrapper', () => { + expect(testContext.bindInOut.eventWrapper).toEqual({}); + }); + + describe('if .in is an input', () => { + beforeEach(() => { + testContext.bindInOut = new BindInOut({ tagName: 'INPUT' }); + }); + + it('should set .eventType to keyup ', () => { + expect(testContext.bindInOut.eventType).toEqual('keyup'); + }); + }); + + describe('if .in is a textarea', () => { + beforeEach(() => { + testContext.bindInOut = new BindInOut({ tagName: 'TEXTAREA' }); + }); + + it('should set .eventType to keyup ', () => { + expect(testContext.bindInOut.eventType).toEqual('keyup'); + }); + }); + + describe('if .in is not an input or textarea', () => { + beforeEach(() => { + testContext.bindInOut = new BindInOut({ tagName: 'SELECT' }); + }); + + it('should set .eventType to change ', () => { + expect(testContext.bindInOut.eventType).toEqual('change'); + }); + }); + }); + + describe('addEvents', () => { + beforeEach(() => { + testContext.in = { + addEventListener: jest.fn(), + }; + + testContext.bindInOut = new BindInOut(testContext.in); + + testContext.addEvents = testContext.bindInOut.addEvents(); + }); + + it('should set .eventWrapper.updateOut', () => { + expect(testContext.bindInOut.eventWrapper.updateOut).toEqual(expect.any(Function)); + }); + + it('should call .addEventListener', () => { + expect(testContext.in.addEventListener).toHaveBeenCalledWith( + testContext.bindInOut.eventType, + testContext.bindInOut.eventWrapper.updateOut, + ); + }); + + it('should return the instance', () => { + expect(testContext.addEvents).toBe(testContext.bindInOut); + }); + }); + + describe('updateOut', () => { + beforeEach(() => { + testContext.in = { value: 'the-value' }; + testContext.out = { textContent: 'not-the-value' }; + + testContext.bindInOut = new BindInOut(testContext.in, testContext.out); + + testContext.updateOut = testContext.bindInOut.updateOut(); + }); + + it('should set .out.textContent to .in.value', () => { + expect(testContext.out.textContent).toBe(testContext.in.value); + }); + + it('should return the instance', () => { + expect(testContext.updateOut).toBe(testContext.bindInOut); + }); + }); + + describe('removeEvents', () => { + beforeEach(() => { + testContext.in = { + removeEventListener: jest.fn(), + }; + testContext.updateOut = () => {}; + + testContext.bindInOut = new BindInOut(testContext.in); + testContext.bindInOut.eventWrapper.updateOut = testContext.updateOut; + + testContext.removeEvents = testContext.bindInOut.removeEvents(); + }); + + it('should call .removeEventListener', () => { + expect(testContext.in.removeEventListener).toHaveBeenCalledWith( + testContext.bindInOut.eventType, + testContext.updateOut, + ); + }); + + it('should return the instance', () => { + expect(testContext.removeEvents).toBe(testContext.bindInOut); + }); + }); + + describe('initAll', () => { + beforeEach(() => { + testContext.ins = [0, 1, 2]; + testContext.instances = []; + + jest.spyOn(document, 'querySelectorAll').mockReturnValue(testContext.ins); + jest.spyOn(Array.prototype, 'map'); + jest.spyOn(BindInOut, 'init').mockImplementation(() => {}); + + testContext.initAll = BindInOut.initAll(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'initAll'); + + it('should call .querySelectorAll', () => { + expect(document.querySelectorAll).toHaveBeenCalledWith('*[data-bind-in]'); + }); + + it('should call .map', () => { + expect(Array.prototype.map).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('should call .init for each element', () => { + expect(BindInOut.init.mock.calls.length).toEqual(3); + }); + + it('should return an array of instances', () => { + expect(testContext.initAll).toEqual(expect.any(Array)); + }); + }); + + describe('init', () => { + beforeEach(() => { + // eslint-disable-next-line func-names + jest.spyOn(BindInOut.prototype, 'addEvents').mockImplementation(function() { + return this; + }); + // eslint-disable-next-line func-names + jest.spyOn(BindInOut.prototype, 'updateOut').mockImplementation(function() { + return this; + }); + + testContext.init = BindInOut.init({}, {}); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'init'); + + it('should call .addEvents', () => { + expect(BindInOut.prototype.addEvents).toHaveBeenCalled(); + }); + + it('should call .updateOut', () => { + expect(BindInOut.prototype.updateOut).toHaveBeenCalled(); + }); + + describe('if no anOut is provided', () => { + beforeEach(() => { + testContext.anIn = { dataset: { bindIn: 'the-data-bind-in' } }; + + jest.spyOn(document, 'querySelector').mockImplementation(() => {}); + + BindInOut.init(testContext.anIn); + }); + + it('should call .querySelector', () => { + expect(document.querySelector).toHaveBeenCalledWith( + `*[data-bind-out="${testContext.anIn.dataset.bindIn}"]`, + ); + }); + }); + }); +}); diff --git a/spec/frontend/bootstrap_jquery_spec.js b/spec/frontend/bootstrap_jquery_spec.js new file mode 100644 index 00000000000..d5d592e3839 --- /dev/null +++ b/spec/frontend/bootstrap_jquery_spec.js @@ -0,0 +1,44 @@ +import $ from 'jquery'; +import '~/commons/bootstrap'; + +describe('Bootstrap jQuery extensions', () => { + describe('disable', () => { + beforeEach(() => { + setFixtures('<input type="text" />'); + }); + + it('adds the disabled attribute', () => { + const $input = $('input').first(); + $input.disable(); + + expect($input).toHaveAttr('disabled', 'disabled'); + }); + + it('adds the disabled class', () => { + const $input = $('input').first(); + $input.disable(); + + expect($input).toHaveClass('disabled'); + }); + }); + + describe('enable', () => { + beforeEach(() => { + setFixtures('<input type="text" disabled="disabled" class="disabled" />'); + }); + + it('removes the disabled attribute', () => { + const $input = $('input').first(); + $input.enable(); + + expect($input).not.toHaveAttr('disabled'); + }); + + it('removes the disabled class', () => { + const $input = $('input').first(); + $input.enable(); + + expect($input).not.toHaveClass('disabled'); + }); + }); +}); diff --git a/spec/frontend/branches/branches_delete_modal_spec.js b/spec/frontend/branches/branches_delete_modal_spec.js new file mode 100644 index 00000000000..21608feafc8 --- /dev/null +++ b/spec/frontend/branches/branches_delete_modal_spec.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; +import DeleteModal from '~/branches/branches_delete_modal'; + +describe('branches delete modal', () => { + describe('setDisableDeleteButton', () => { + let submitSpy; + let $deleteButton; + + beforeEach(() => { + setFixtures(` + <div id="modal-delete-branch"> + <form> + <button type="submit" class="js-delete-branch">Delete</button> + </form> + </div> + `); + $deleteButton = $('.js-delete-branch'); + submitSpy = jest.fn(event => event.preventDefault()); + $('#modal-delete-branch form').on('submit', submitSpy); + // eslint-disable-next-line no-new + new DeleteModal(); + }); + + it('does not submit if button is disabled', () => { + $deleteButton.attr('disabled', true); + + $deleteButton.click(); + + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('submits if button is not disabled', () => { + $deleteButton.attr('disabled', false); + + $deleteButton.click(); + + expect(submitSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/breakpoints_spec.js b/spec/frontend/breakpoints_spec.js new file mode 100644 index 00000000000..c9014ddd3e2 --- /dev/null +++ b/spec/frontend/breakpoints_spec.js @@ -0,0 +1,27 @@ +import bp, { breakpoints } from '~/breakpoints'; + +describe('breakpoints', () => { + Object.keys(breakpoints).forEach(key => { + const size = breakpoints[key]; + + it(`returns ${key} when larger than ${size}`, () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(size + 10); + + expect(bp.getBreakpointSize()).toBe(key); + }); + }); + + describe('isDesktop', () => { + it('returns true when screen size is medium', () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(breakpoints.md + 10); + + expect(bp.isDesktop()).toBe(true); + }); + + it('returns false when screen size is small', () => { + jest.spyOn(bp, 'windowWidth').mockReturnValue(breakpoints.sm + 10); + + expect(bp.isDesktop()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js new file mode 100644 index 00000000000..c360f5584ca --- /dev/null +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -0,0 +1,167 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import diffModule from '~/diffs/store/modules'; +import SettingsDropdown from '~/diffs/components/settings_dropdown.vue'; +import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Diff settiings dropdown component', () => { + let vm; + let actions; + + function createComponent(extendStore = () => {}) { + const store = new Vuex.Store({ + modules: { + diffs: { + namespaced: true, + actions, + state: diffModule().state, + getters: diffModule().getters, + }, + }, + }); + + extendStore(store); + + vm = mount(localVue.extend(SettingsDropdown), { + localVue, + store, + sync: false, + }); + } + + beforeEach(() => { + actions = { + setInlineDiffViewType: jest.fn(), + setParallelDiffViewType: jest.fn(), + setRenderTreeList: jest.fn(), + setShowWhitespace: jest.fn(), + }; + }); + + afterEach(() => { + vm.destroy(); + }); + + describe('tree view buttons', () => { + it('list view button dispatches setRenderTreeList with false', () => { + createComponent(); + + vm.find('.js-list-view').trigger('click'); + + expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false, undefined); + }); + + it('tree view button dispatches setRenderTreeList with true', () => { + createComponent(); + + vm.find('.js-tree-view').trigger('click'); + + expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined); + }); + + it('sets list button as active when renderTreeList is false', () => { + createComponent(store => { + Object.assign(store.state.diffs, { + renderTreeList: false, + }); + }); + + expect(vm.find('.js-list-view').classes('active')).toBe(true); + expect(vm.find('.js-tree-view').classes('active')).toBe(false); + }); + + it('sets tree button as active when renderTreeList is true', () => { + createComponent(store => { + Object.assign(store.state.diffs, { + renderTreeList: true, + }); + }); + + expect(vm.find('.js-list-view').classes('active')).toBe(false); + expect(vm.find('.js-tree-view').classes('active')).toBe(true); + }); + }); + + describe('compare changes', () => { + it('sets inline button as active', () => { + createComponent(store => { + Object.assign(store.state.diffs, { + diffViewType: INLINE_DIFF_VIEW_TYPE, + }); + }); + + expect(vm.find('.js-inline-diff-button').classes('active')).toBe(true); + expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(false); + }); + + it('sets parallel button as active', () => { + createComponent(store => { + Object.assign(store.state.diffs, { + diffViewType: PARALLEL_DIFF_VIEW_TYPE, + }); + }); + + expect(vm.find('.js-inline-diff-button').classes('active')).toBe(false); + expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(true); + }); + + it('calls setInlineDiffViewType when clicking inline button', () => { + createComponent(); + + vm.find('.js-inline-diff-button').trigger('click'); + + expect(actions.setInlineDiffViewType).toHaveBeenCalled(); + }); + + it('calls setParallelDiffViewType when clicking parallel button', () => { + createComponent(); + + vm.find('.js-parallel-diff-button').trigger('click'); + + expect(actions.setParallelDiffViewType).toHaveBeenCalled(); + }); + }); + + describe('whitespace toggle', () => { + it('does not set as checked when showWhitespace is false', () => { + createComponent(store => { + Object.assign(store.state.diffs, { + showWhitespace: false, + }); + }); + + expect(vm.find('#show-whitespace').element.checked).toBe(false); + }); + + it('sets as checked when showWhitespace is true', () => { + createComponent(store => { + Object.assign(store.state.diffs, { + showWhitespace: true, + }); + }); + + expect(vm.find('#show-whitespace').element.checked).toBe(true); + }); + + it('calls setShowWhitespace on change', () => { + createComponent(); + + const checkbox = vm.find('#show-whitespace'); + + checkbox.element.checked = true; + checkbox.trigger('change'); + + expect(actions.setShowWhitespace).toHaveBeenCalledWith( + expect.anything(), + { + showWhitespace: true, + pushState: true, + }, + undefined, + ); + }); + }); +}); diff --git a/spec/frontend/droplab/constants_spec.js b/spec/frontend/droplab/constants_spec.js new file mode 100644 index 00000000000..fd48228d6a2 --- /dev/null +++ b/spec/frontend/droplab/constants_spec.js @@ -0,0 +1,39 @@ +import * as constants from '~/droplab/constants'; + +describe('constants', () => { + describe('DATA_TRIGGER', () => { + it('should be `data-dropdown-trigger`', () => { + expect(constants.DATA_TRIGGER).toBe('data-dropdown-trigger'); + }); + }); + + describe('DATA_DROPDOWN', () => { + it('should be `data-dropdown`', () => { + expect(constants.DATA_DROPDOWN).toBe('data-dropdown'); + }); + }); + + describe('SELECTED_CLASS', () => { + it('should be `droplab-item-selected`', () => { + expect(constants.SELECTED_CLASS).toBe('droplab-item-selected'); + }); + }); + + describe('ACTIVE_CLASS', () => { + it('should be `droplab-item-active`', () => { + expect(constants.ACTIVE_CLASS).toBe('droplab-item-active'); + }); + }); + + describe('TEMPLATE_REGEX', () => { + it('should be a handlebars templating syntax regex', () => { + expect(constants.TEMPLATE_REGEX).toEqual(/\{\{(.+?)\}\}/g); + }); + }); + + describe('IGNORE_CLASS', () => { + it('should be `droplab-item-ignore`', () => { + expect(constants.IGNORE_CLASS).toBe('droplab-item-ignore'); + }); + }); +}); diff --git a/spec/frontend/droplab/plugins/ajax_filter_spec.js b/spec/frontend/droplab/plugins/ajax_filter_spec.js new file mode 100644 index 00000000000..5ec0400cbc5 --- /dev/null +++ b/spec/frontend/droplab/plugins/ajax_filter_spec.js @@ -0,0 +1,72 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import AjaxFilter from '~/droplab/plugins/ajax_filter'; + +describe('AjaxFilter', () => { + let dummyConfig; + const dummyData = 'dummy data'; + let dummyList; + + beforeEach(() => { + dummyConfig = { + endpoint: 'dummy endpoint', + searchKey: 'dummy search key', + }; + dummyList = { + data: [], + list: document.createElement('div'), + }; + + AjaxFilter.hook = { + config: { + AjaxFilter: dummyConfig, + }, + list: dummyList, + }; + }); + + describe('trigger', () => { + let ajaxSpy; + + beforeEach(() => { + jest.spyOn(AjaxCache, 'retrieve').mockImplementation(url => ajaxSpy(url)); + jest.spyOn(AjaxFilter, '_loadData').mockImplementation(() => {}); + + dummyConfig.onLoadingFinished = jest.fn(); + + const dynamicList = document.createElement('div'); + dynamicList.dataset.dynamic = true; + dummyList.list.appendChild(dynamicList); + }); + + it('calls onLoadingFinished after loading data', done => { + ajaxSpy = url => { + expect(url).toBe('dummy endpoint?dummy search key='); + return Promise.resolve(dummyData); + }; + + AjaxFilter.trigger() + .then(() => { + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + + it('does not call onLoadingFinished if Ajax call fails', done => { + const dummyError = new Error('My dummy is sick! :-('); + ajaxSpy = url => { + expect(url).toBe('dummy endpoint?dummy search key='); + return Promise.reject(dummyError); + }; + + AjaxFilter.trigger() + .then(done.fail) + .catch(error => { + expect(error).toBe(dummyError); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/droplab/plugins/ajax_spec.js b/spec/frontend/droplab/plugins/ajax_spec.js new file mode 100644 index 00000000000..1d7576ce420 --- /dev/null +++ b/spec/frontend/droplab/plugins/ajax_spec.js @@ -0,0 +1,41 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import Ajax from '~/droplab/plugins/ajax'; + +describe('Ajax', () => { + describe('preprocessing', () => { + const config = {}; + + describe('is not configured', () => { + it('passes the data through', () => { + const data = ['data']; + + expect(Ajax.preprocessing(config, data)).toEqual(data); + }); + }); + + describe('is configured', () => { + const processedArray = ['processed']; + + beforeEach(() => { + config.preprocessing = () => processedArray; + jest.spyOn(config, 'preprocessing').mockImplementation(() => processedArray); + }); + + it('calls preprocessing', () => { + Ajax.preprocessing(config, []); + + expect(config.preprocessing.mock.calls.length).toBe(1); + }); + + it('overrides AjaxCache', () => { + jest.spyOn(AjaxCache, 'override').mockImplementation((endpoint, results) => { + expect(results).toEqual(processedArray); + }); + + Ajax.preprocessing(config, []); + + expect(AjaxCache.override.mock.calls.length).toBe(1); + }); + }); + }); +}); diff --git a/spec/frontend/feature_highlight/feature_highlight_options_spec.js b/spec/frontend/feature_highlight/feature_highlight_options_spec.js new file mode 100644 index 00000000000..cd41d1ed091 --- /dev/null +++ b/spec/frontend/feature_highlight/feature_highlight_options_spec.js @@ -0,0 +1,30 @@ +import domContentLoaded from '~/feature_highlight/feature_highlight_options'; +import bp from '~/breakpoints'; + +describe('feature highlight options', () => { + describe('domContentLoaded', () => { + it('should not call highlightFeatures when breakpoint is xs', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('xs'); + + expect(domContentLoaded()).toBe(false); + }); + + it('should not call highlightFeatures when breakpoint is sm', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('sm'); + + expect(domContentLoaded()).toBe(false); + }); + + it('should not call highlightFeatures when breakpoint is md', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('md'); + + expect(domContentLoaded()).toBe(false); + }); + + it('should call highlightFeatures when breakpoint is lg', () => { + jest.spyOn(bp, 'getBreakpointSize').mockReturnValue('lg'); + + expect(domContentLoaded()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js new file mode 100644 index 00000000000..2543fb8768b --- /dev/null +++ b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -0,0 +1,201 @@ +import Vue from 'vue'; +import eventHub from '~/filtered_search/event_hub'; +import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue'; +import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; + +const createComponent = propsData => { + const Component = Vue.extend(RecentSearchesDropdownContent); + + return new Component({ + el: document.createElement('div'), + propsData, + }); +}; + +// Remove all the newlines and whitespace from the formatted markup +const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); + +describe('RecentSearchesDropdownContent', () => { + const propsDataWithoutItems = { + items: [], + allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(), + }; + const propsDataWithItems = { + items: ['foo', 'author:@root label:~foo bar'], + allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(), + }; + + let vm; + afterEach(() => { + if (vm) { + vm.$destroy(); + } + }); + + describe('with no items', () => { + let el; + + beforeEach(() => { + vm = createComponent(propsDataWithoutItems); + el = vm.$el; + }); + + it('should render empty state', () => { + expect(el.querySelector('.dropdown-info-note')).toBeDefined(); + + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + + describe('with items', () => { + let el; + + beforeEach(() => { + vm = createComponent(propsDataWithItems); + el = vm.$el; + }); + + it('should render clear recent searches button', () => { + expect(el.querySelector('.filtered-search-history-clear-button')).toBeDefined(); + }); + + it('should render recent search items', () => { + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + + expect(items.length).toEqual(propsDataWithItems.items.length); + + expect( + trimMarkupWhitespace( + items[0].querySelector('.filtered-search-history-dropdown-search-token').textContent, + ), + ).toEqual('foo'); + + const item1Tokens = items[1].querySelectorAll('.filtered-search-history-dropdown-token'); + + expect(item1Tokens.length).toEqual(2); + expect(item1Tokens[0].querySelector('.name').textContent).toEqual('author:'); + expect(item1Tokens[0].querySelector('.value').textContent).toEqual('@root'); + expect(item1Tokens[1].querySelector('.name').textContent).toEqual('label:'); + expect(item1Tokens[1].querySelector('.value').textContent).toEqual('~foo'); + expect( + trimMarkupWhitespace( + items[1].querySelector('.filtered-search-history-dropdown-search-token').textContent, + ), + ).toEqual('bar'); + }); + }); + + describe('if isLocalStorageAvailable is `false`', () => { + let el; + + beforeEach(() => { + const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems); + + vm = createComponent(props); + el = vm.$el; + }); + + it('should render an info note', () => { + const note = el.querySelector('.dropdown-info-note'); + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + + expect(note).toBeDefined(); + expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled'); + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + + describe('computed', () => { + describe('processedItems', () => { + it('with items', () => { + vm = createComponent(propsDataWithItems); + const { processedItems } = vm; + + expect(processedItems.length).toEqual(2); + + expect(processedItems[0].text).toEqual(propsDataWithItems.items[0]); + expect(processedItems[0].tokens).toEqual([]); + expect(processedItems[0].searchToken).toEqual('foo'); + + expect(processedItems[1].text).toEqual(propsDataWithItems.items[1]); + expect(processedItems[1].tokens.length).toEqual(2); + expect(processedItems[1].tokens[0].prefix).toEqual('author:'); + expect(processedItems[1].tokens[0].suffix).toEqual('@root'); + expect(processedItems[1].tokens[1].prefix).toEqual('label:'); + expect(processedItems[1].tokens[1].suffix).toEqual('~foo'); + expect(processedItems[1].searchToken).toEqual('bar'); + }); + + it('with no items', () => { + vm = createComponent(propsDataWithoutItems); + const { processedItems } = vm; + + expect(processedItems.length).toEqual(0); + }); + }); + + describe('hasItems', () => { + it('with items', () => { + vm = createComponent(propsDataWithItems); + const { hasItems } = vm; + + expect(hasItems).toEqual(true); + }); + + it('with no items', () => { + vm = createComponent(propsDataWithoutItems); + const { hasItems } = vm; + + expect(hasItems).toEqual(false); + }); + }); + }); + + describe('methods', () => { + describe('onItemActivated', () => { + let onRecentSearchesItemSelectedSpy; + + beforeEach(() => { + onRecentSearchesItemSelectedSpy = jest.fn(); + eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); + + vm = createComponent(propsDataWithItems); + }); + + afterEach(() => { + eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); + }); + + it('emits event', () => { + expect(onRecentSearchesItemSelectedSpy).not.toHaveBeenCalled(); + vm.onItemActivated('something'); + + expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('something'); + }); + }); + + describe('onRequestClearRecentSearches', () => { + let onRequestClearRecentSearchesSpy; + + beforeEach(() => { + onRequestClearRecentSearchesSpy = jest.fn(); + eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy); + + vm = createComponent(propsDataWithItems); + }); + + afterEach(() => { + eventHub.$off('requestClearRecentSearches', onRequestClearRecentSearchesSpy); + }); + + it('emits event', () => { + expect(onRequestClearRecentSearchesSpy).not.toHaveBeenCalled(); + vm.onRequestClearRecentSearches({ stopPropagation: () => {} }); + + expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/filtered_search/dropdown_user_spec.js b/spec/frontend/filtered_search/dropdown_user_spec.js new file mode 100644 index 00000000000..8eef10290bf --- /dev/null +++ b/spec/frontend/filtered_search/dropdown_user_spec.js @@ -0,0 +1,113 @@ +import DropdownUtils from '~/filtered_search/dropdown_utils'; +import DropdownUser from '~/filtered_search/dropdown_user'; +import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; +import IssuableFilteredTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; + +describe('Dropdown User', () => { + describe('getSearchInput', () => { + let dropdownUser; + + beforeEach(() => { + jest.spyOn(DropdownUser.prototype, 'bindEvents').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getProjectId').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getGroupId').mockImplementation(() => {}); + jest.spyOn(DropdownUtils, 'getSearchInput').mockImplementation(() => {}); + + dropdownUser = new DropdownUser({ + tokenKeys: IssuableFilteredTokenKeys, + }); + }); + + it('should not return the double quote found in value', () => { + jest.spyOn(FilteredSearchTokenizer, 'processTokens').mockReturnValue({ + lastToken: '"johnny appleseed', + }); + + expect(dropdownUser.getSearchInput()).toBe('johnny appleseed'); + }); + + it('should not return the single quote found in value', () => { + jest.spyOn(FilteredSearchTokenizer, 'processTokens').mockReturnValue({ + lastToken: "'larry boy", + }); + + expect(dropdownUser.getSearchInput()).toBe('larry boy'); + }); + }); + + describe("config AjaxFilter's endpoint", () => { + beforeEach(() => { + jest.spyOn(DropdownUser.prototype, 'bindEvents').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getProjectId').mockImplementation(() => {}); + jest.spyOn(DropdownUser.prototype, 'getGroupId').mockImplementation(() => {}); + }); + + it('should return endpoint', () => { + window.gon = { + relative_url_root: '', + }; + const dropdown = new DropdownUser(); + + expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json'); + }); + + it('should return endpoint when relative_url_root is undefined', () => { + const dropdown = new DropdownUser(); + + expect(dropdown.config.AjaxFilter.endpoint).toBe('/autocomplete/users.json'); + }); + + it('should return endpoint with relative url when available', () => { + window.gon = { + relative_url_root: '/gitlab_directory', + }; + const dropdown = new DropdownUser(); + + expect(dropdown.config.AjaxFilter.endpoint).toBe('/gitlab_directory/autocomplete/users.json'); + }); + + afterEach(() => { + window.gon = {}; + }); + }); + + describe('hideCurrentUser', () => { + const fixtureTemplate = 'issues/issue_list.html'; + preloadFixtures(fixtureTemplate); + + let dropdown; + let authorFilterDropdownElement; + + beforeEach(() => { + loadFixtures(fixtureTemplate); + authorFilterDropdownElement = document.querySelector('#js-dropdown-author'); + const dummyInput = document.createElement('div'); + dropdown = new DropdownUser({ + dropdown: authorFilterDropdownElement, + input: dummyInput, + }); + }); + + const findCurrentUserElement = () => + authorFilterDropdownElement.querySelector('.js-current-user'); + + it('hides the current user from dropdown', () => { + const currentUserElement = findCurrentUserElement(); + + expect(currentUserElement).not.toBe(null); + + dropdown.hideCurrentUser(); + + expect(currentUserElement.classList).toContain('hidden'); + }); + + it('does nothing if no user is logged in', () => { + const currentUserElement = findCurrentUserElement(); + currentUserElement.parentNode.removeChild(currentUserElement); + + expect(findCurrentUserElement()).toBe(null); + + dropdown.hideCurrentUser(); + }); + }); +}); diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js new file mode 100644 index 00000000000..e5f1ab21c7f --- /dev/null +++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js @@ -0,0 +1,83 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import searchComponent from '~/frequent_items/components/frequent_items_search_input.vue'; +import eventHub from '~/frequent_items/event_hub'; + +const localVue = createLocalVue(); + +const createComponent = (namespace = 'projects') => + shallowMount(localVue.extend(searchComponent), { + propsData: { namespace }, + localVue, + sync: false, + }); + +describe('FrequentItemsSearchInputComponent', () => { + let wrapper; + let vm; + + beforeEach(() => { + wrapper = createComponent(); + + ({ vm } = wrapper); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('setFocus', () => { + it('should set focus to search input', () => { + jest.spyOn(vm.$refs.search, 'focus').mockImplementation(() => {}); + + vm.setFocus(); + + expect(vm.$refs.search.focus).toHaveBeenCalled(); + }); + }); + }); + + describe('mounted', () => { + it('should listen `dropdownOpen` event', done => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + const vmX = createComponent().vm; + + localVue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith( + `${vmX.namespace}-dropdownOpen`, + expect.any(Function), + ); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', done => { + const vmX = createComponent().vm; + jest.spyOn(eventHub, '$off').mockImplementation(() => {}); + + vmX.$mount(); + vmX.$destroy(); + + localVue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith( + `${vmX.namespace}-dropdownOpen`, + expect.any(Function), + ); + done(); + }); + }); + }); + + describe('template', () => { + it('should render component element', () => { + expect(wrapper.classes()).toContain('search-input-container'); + expect(wrapper.contains('input.form-control')).toBe(true); + expect(wrapper.contains('.search-icon')).toBe(true); + expect(wrapper.find('input.form-control').attributes('placeholder')).toBe( + 'Search your projects', + ); + }); + }); +}); diff --git a/spec/frontend/gl_field_errors_spec.js b/spec/frontend/gl_field_errors_spec.js new file mode 100644 index 00000000000..4653f519f65 --- /dev/null +++ b/spec/frontend/gl_field_errors_spec.js @@ -0,0 +1,144 @@ +/* eslint-disable arrow-body-style */ + +import $ from 'jquery'; +import GlFieldErrors from '~/gl_field_errors'; + +describe('GL Style Field Errors', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + preloadFixtures('static/gl_field_errors.html'); + + beforeEach(() => { + loadFixtures('static/gl_field_errors.html'); + const $form = $('form.gl-show-field-errors'); + + testContext.$form = $form; + testContext.fieldErrors = new GlFieldErrors($form); + }); + + it('should select the correct input elements', () => { + expect(testContext.$form).toBeDefined(); + expect(testContext.$form.length).toBe(1); + expect(testContext.fieldErrors).toBeDefined(); + const { inputs } = testContext.fieldErrors.state; + + expect(inputs.length).toBe(4); + }); + + it('should ignore elements with custom error handling', () => { + const customErrorFlag = 'gl-field-error-ignore'; + const customErrorElem = $(`.${customErrorFlag}`); + + expect(customErrorElem.length).toBe(1); + + const customErrors = testContext.fieldErrors.state.inputs.filter(input => { + return input.inputElement.hasClass(customErrorFlag); + }); + + expect(customErrors.length).toBe(0); + }); + + it('should not show any errors before submit attempt', () => { + testContext.$form + .find('.email') + .val('not-a-valid-email') + .keyup(); + testContext.$form + .find('.text-required') + .val('') + .keyup(); + testContext.$form + .find('.alphanumberic') + .val('?---*') + .keyup(); + + const errorsShown = testContext.$form.find('.gl-field-error-outline'); + + expect(errorsShown.length).toBe(0); + }); + + it('should show errors when input valid is submitted', () => { + testContext.$form + .find('.email') + .val('not-a-valid-email') + .keyup(); + testContext.$form + .find('.text-required') + .val('') + .keyup(); + testContext.$form + .find('.alphanumberic') + .val('?---*') + .keyup(); + + testContext.$form.submit(); + + const errorsShown = testContext.$form.find('.gl-field-error-outline'); + + expect(errorsShown.length).toBe(4); + }); + + it('should properly track validity state on input after invalid submission attempt', () => { + testContext.$form.submit(); + + const emailInputModel = testContext.fieldErrors.state.inputs[1]; + const fieldState = emailInputModel.state; + const emailInputElement = emailInputModel.inputElement; + + // No input + expect(emailInputElement).toHaveClass('gl-field-error-outline'); + expect(fieldState.empty).toBe(true); + expect(fieldState.valid).toBe(false); + + // Then invalid input + emailInputElement.val('not-a-valid-email').keyup(); + + expect(emailInputElement).toHaveClass('gl-field-error-outline'); + expect(fieldState.empty).toBe(false); + expect(fieldState.valid).toBe(false); + + // Then valid input + emailInputElement.val('email@gitlab.com').keyup(); + + expect(emailInputElement).not.toHaveClass('gl-field-error-outline'); + expect(fieldState.empty).toBe(false); + expect(fieldState.valid).toBe(true); + + // Then invalid input + emailInputElement.val('not-a-valid-email').keyup(); + + expect(emailInputElement).toHaveClass('gl-field-error-outline'); + expect(fieldState.empty).toBe(false); + expect(fieldState.valid).toBe(false); + + // Then empty input + emailInputElement.val('').keyup(); + + expect(emailInputElement).toHaveClass('gl-field-error-outline'); + expect(fieldState.empty).toBe(true); + expect(fieldState.valid).toBe(false); + + // Then valid input + emailInputElement.val('email@gitlab.com').keyup(); + + expect(emailInputElement).not.toHaveClass('gl-field-error-outline'); + expect(fieldState.empty).toBe(false); + expect(fieldState.valid).toBe(true); + }); + + it('should properly infer error messages', () => { + testContext.$form.submit(); + const trackedInputs = testContext.fieldErrors.state.inputs; + const inputHasTitle = trackedInputs[1]; + const hasTitleErrorElem = inputHasTitle.inputElement.siblings('.gl-field-error'); + const inputNoTitle = trackedInputs[2]; + const noTitleErrorElem = inputNoTitle.inputElement.siblings('.gl-field-error'); + + expect(noTitleErrorElem.text()).toBe('This field is required.'); + expect(hasTitleErrorElem.text()).toBe('Please provide a valid email address.'); + }); +}); diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js new file mode 100644 index 00000000000..809cc5c88e2 --- /dev/null +++ b/spec/frontend/gpg_badges_spec.js @@ -0,0 +1,92 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'spec/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import GpgBadges from '~/gpg_badges'; + +describe('GpgBadges', () => { + let mock; + const dummyCommitSha = 'n0m0rec0ffee'; + const dummyBadgeHtml = 'dummy html'; + const dummyResponse = { + signatures: [ + { + commit_sha: dummyCommitSha, + html: dummyBadgeHtml, + }, + ], + }; + const dummyUrl = `${TEST_HOST}/dummy/signatures`; + + beforeEach(() => { + mock = new MockAdapter(axios); + setFixtures(` + <form + class="commits-search-form js-signature-container" data-signatures-path="${dummyUrl}" action="${dummyUrl}" + method="get"> + <input name="utf8" type="hidden" value="✓"> + <input type="search" name="search" id="commits-search"class="form-control search-text-input input-short"> + </form> + <div class="parent-container"> + <div class="js-loading-gpg-badge" data-commit-sha="${dummyCommitSha}"></div> + </div> + `); + }); + + afterEach(() => { + mock.restore(); + }); + + it('does not make a request if there is no container element', done => { + setFixtures(''); + jest.spyOn(axios, 'get').mockImplementation(() => {}); + + GpgBadges.fetch() + .then(() => { + expect(axios.get).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('throws an error if the endpoint is missing', done => { + setFixtures('<div class="js-signature-container"></div>'); + jest.spyOn(axios, 'get').mockImplementation(() => {}); + + GpgBadges.fetch() + .then(() => done.fail('Expected error to be thrown')) + .catch(error => { + expect(error.message).toBe('Missing commit signatures endpoint!'); + expect(axios.get).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('displays a loading spinner', done => { + mock.onGet(dummyUrl).replyOnce(200); + + GpgBadges.fetch() + .then(() => { + expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null); + const spinners = document.querySelectorAll('.js-loading-gpg-badge i.fa.fa-spinner.fa-spin'); + + expect(spinners.length).toBe(1); + done(); + }) + .catch(done.fail); + }); + + it('replaces the loading spinner', done => { + mock.onGet(dummyUrl).replyOnce(200, dummyResponse); + + GpgBadges.fetch() + .then(() => { + expect(document.querySelector('.js-loading-gpg-badge')).toBe(null); + const parentContainer = document.querySelector('.parent-container'); + + expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml); + done(); + }) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js new file mode 100644 index 00000000000..00b5b306d66 --- /dev/null +++ b/spec/frontend/header_spec.js @@ -0,0 +1,53 @@ +import $ from 'jquery'; +import initTodoToggle from '~/header'; + +describe('Header', () => { + const todosPendingCount = '.todos-count'; + const fixtureTemplate = 'issues/open-issue.html'; + + function isTodosCountHidden() { + return $(todosPendingCount).hasClass('hidden'); + } + + function triggerToggle(newCount) { + $(document).trigger('todo:toggle', newCount); + } + + preloadFixtures(fixtureTemplate); + beforeEach(() => { + initTodoToggle(); + loadFixtures(fixtureTemplate); + }); + + it('should update todos-count after receiving the todo:toggle event', () => { + triggerToggle(5); + + expect($(todosPendingCount).text()).toEqual('5'); + }); + + it('should hide todos-count when it is 0', () => { + triggerToggle(0); + + expect(isTodosCountHidden()).toEqual(true); + }); + + it('should show todos-count when it is more than 0', () => { + triggerToggle(10); + + expect(isTodosCountHidden()).toEqual(false); + }); + + describe('when todos-count is 1000', () => { + beforeEach(() => { + triggerToggle(1000); + }); + + it('should show todos-count', () => { + expect(isTodosCountHidden()).toEqual(false); + }); + + it('should show 99+ for todos-count', () => { + expect($(todosPendingCount).text()).toEqual('99+'); + }); + }); +}); diff --git a/spec/frontend/helpers/class_spec_helper_spec.js b/spec/frontend/helpers/class_spec_helper_spec.js new file mode 100644 index 00000000000..533d5687bde --- /dev/null +++ b/spec/frontend/helpers/class_spec_helper_spec.js @@ -0,0 +1,26 @@ +/* global ClassSpecHelper */ + +import './class_spec_helper'; + +describe('ClassSpecHelper', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('itShouldBeAStaticMethod', () => { + beforeEach(() => { + class TestClass { + instanceMethod() { + this.prop = 'val'; + } + static staticMethod() {} + } + + testContext.TestClass = TestClass; + }); + + ClassSpecHelper.itShouldBeAStaticMethod(ClassSpecHelper, 'itShouldBeAStaticMethod'); + }); +}); diff --git a/spec/frontend/ide/components/commit_sidebar/stage_button_spec.js b/spec/frontend/ide/components/commit_sidebar/stage_button_spec.js new file mode 100644 index 00000000000..b59de4dac0e --- /dev/null +++ b/spec/frontend/ide/components/commit_sidebar/stage_button_spec.js @@ -0,0 +1,46 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import stageButton from '~/ide/components/commit_sidebar/stage_button.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../../helpers'; + +describe('IDE stage file button', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(stageButton); + f = file(); + + vm = createComponentWithStore(Component, store, { + path: f.path, + }); + + jest.spyOn(vm, 'stageChange').mockImplementation(() => {}); + jest.spyOn(vm, 'discardFileChanges').mockImplementation(() => {}); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders button to discard & stage', () => { + expect(vm.$el.querySelectorAll('.btn-blank').length).toBe(2); + }); + + it('calls store with stage button', () => { + vm.$el.querySelectorAll('.btn')[0].click(); + + expect(vm.stageChange).toHaveBeenCalledWith(f.path); + }); + + it('calls store with discard button', () => { + vm.$el.querySelector('.btn-danger').click(); + + expect(vm.discardFileChanges).toHaveBeenCalledWith(f.path); + }); +}); diff --git a/spec/frontend/ide/components/commit_sidebar/unstage_button_spec.js b/spec/frontend/ide/components/commit_sidebar/unstage_button_spec.js new file mode 100644 index 00000000000..53b53c8c815 --- /dev/null +++ b/spec/frontend/ide/components/commit_sidebar/unstage_button_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import unstageButton from '~/ide/components/commit_sidebar/unstage_button.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../../helpers'; + +describe('IDE unstage file button', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(unstageButton); + f = file(); + + vm = createComponentWithStore(Component, store, { + path: f.path, + }); + + jest.spyOn(vm, 'unstageChange').mockImplementation(() => {}); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders button to unstage', () => { + expect(vm.$el.querySelectorAll('.btn').length).toBe(1); + }); + + it('calls store with unnstage button', () => { + vm.$el.querySelector('.btn').click(); + + expect(vm.unstageChange).toHaveBeenCalledWith(f.path); + }); +}); diff --git a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js new file mode 100644 index 00000000000..096851a5401 --- /dev/null +++ b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js @@ -0,0 +1,59 @@ +import Vue from 'vue'; +import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue'; +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +describe('IDE job log scroll button', () => { + const Component = Vue.extend(ScrollButton); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + direction: 'up', + disabled: false, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('iconName', () => { + ['up', 'down'].forEach(direction => { + it(`returns icon name for ${direction}`, () => { + vm.direction = direction; + + expect(vm.iconName).toBe(`scroll_${direction}`); + }); + }); + }); + + describe('tooltipTitle', () => { + it('returns title for up', () => { + expect(vm.tooltipTitle).toBe('Scroll to top'); + }); + + it('returns title for down', () => { + vm.direction = 'down'; + + expect(vm.tooltipTitle).toBe('Scroll to bottom'); + }); + }); + + it('emits click event on click', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + + vm.$el.querySelector('.btn-scroll').click(); + + expect(vm.$emit).toHaveBeenCalledWith('click'); + }); + + it('disables button when disabled is true', done => { + vm.disabled = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.btn-scroll').hasAttribute('disabled')).toBe(true); + + done(); + }); + }); +}); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js new file mode 100644 index 00000000000..283ea266821 --- /dev/null +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -0,0 +1,805 @@ +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import store from '~/ide/stores'; +import * as actions from '~/ide/stores/actions/file'; +import * as types from '~/ide/stores/mutation_types'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import eventHub from '~/ide/eventhub'; +import { file, resetStore } from '../../helpers'; +import testAction from '../../../helpers/vuex_action_helper'; + +const RELATIVE_URL_ROOT = '/gitlab'; + +describe('IDE store file actions', () => { + let mock; + let originalGon; + + beforeEach(() => { + mock = new MockAdapter(axios); + originalGon = window.gon; + window.gon = { + ...window.gon, + relative_url_root: RELATIVE_URL_ROOT, + }; + + jest.spyOn(router, 'push').mockImplementation(() => {}); + }); + + afterEach(() => { + mock.restore(); + resetStore(store); + window.gon = originalGon; + }); + + describe('closeFile', () => { + let localFile; + + beforeEach(() => { + localFile = file('testFile'); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + store.state.entries[localFile.path] = localFile; + }); + + it('closes open files', done => { + store + .dispatch('closeFile', localFile) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes file even if file has changes', done => { + store.state.changedFiles.push(localFile); + + store + .dispatch('closeFile', localFile) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('closes file & opens next available file', done => { + const f = { + ...file('newOpenFile'), + url: '/newOpenFile', + }; + + store.state.openFiles.push(f); + store.state.entries[f.path] = f; + + store + .dispatch('closeFile', localFile) + .then(Vue.nextTick) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + + done(); + }) + .catch(done.fail); + }); + + it('removes file if it pending', done => { + store.state.openFiles.push({ + ...localFile, + pending: true, + }); + + store + .dispatch('closeFile', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let localFile; + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jest.fn(); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + localFile = file('setThisActive'); + + store.state.entries[localFile.path] = localFile; + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', () => { + const dispatch = jest.fn(); + + actions.setFileActive( + { commit() {}, state: store.state, getters: store.getters, dispatch }, + localFile.path, + ); + + expect(dispatch).toHaveBeenCalledWith('scrollToTab'); + }); + + it('commits SET_FILE_ACTIVE', () => { + const commit = jest.fn(); + + actions.setFileActive( + { commit, state: store.state, getters: store.getters, dispatch() {} }, + localFile.path, + ); + + expect(commit).toHaveBeenCalledWith('SET_FILE_ACTIVE', { + path: localFile.path, + active: true, + }); + }); + + it('sets current active file to not active', () => { + const f = file('newActive'); + store.state.entries[f.path] = f; + localFile.active = true; + store.state.openFiles.push(localFile); + + const commit = jest.fn(); + + actions.setFileActive( + { commit, state: store.state, getters: store.getters, dispatch() {} }, + f.path, + ); + + expect(commit).toHaveBeenCalledWith('SET_FILE_ACTIVE', { + path: localFile.path, + active: false, + }); + }); + }); + + describe('getFileData', () => { + let localFile; + + beforeEach(() => { + jest.spyOn(service, 'getFileData'); + + localFile = file(`newCreate-${Math.random()}`); + store.state.entries[localFile.path] = localFile; + + store.state.currentProjectId = 'test/test'; + store.state.currentBranchId = 'master'; + + store.state.projects['test/test'] = { + branches: { + master: { + commit: { + id: '7297abc', + }, + }, + }, + }; + }); + + describe('success', () => { + beforeEach(() => { + mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`).replyOnce( + 200, + { + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }, + { + 'page-title': 'testing getFileData', + }, + ); + }); + + it('calls the service', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith( + `${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`, + ); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file data', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }) + .catch(done.fail); + }); + + it('sets document title with the branchId', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(document.title).toBe(`${localFile.path} · master · test/test · GitLab`); + done(); + }) + .catch(done.fail); + }); + + it('sets the file as active', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file not as active if we pass makeFileActive false', done => { + store + .dispatch('getFileData', { path: localFile.path, makeFileActive: false }) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('Re-named success', () => { + beforeEach(() => { + localFile = file(`newCreate-${Math.random()}`); + localFile.url = `project/getFileDataURL`; + localFile.prevPath = 'old-dull-file'; + localFile.path = 'new-shiny-file'; + store.state.entries[localFile.path] = localFile; + + mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/old-dull-file`).replyOnce( + 200, + { + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }, + { + 'page-title': 'testing old-dull-file', + }, + ); + }); + + it('sets document title considering `prevPath` on a file', done => { + store + .dispatch('getFileData', { path: localFile.path }) + .then(() => { + expect(document.title).toBe(`new-shiny-file · master · test/test · GitLab`); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(`${RELATIVE_URL_ROOT}/test/test/7297abc/${localFile.path}`).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jest.fn(); + + actions + .getFileData( + { state: store.state, commit() {}, dispatch, getters: store.getters }, + { path: localFile.path }, + ) + .then(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred whilst loading the file.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + path: localFile.path, + makeFileActive: true, + }, + }); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + jest.spyOn(service, 'getRawFileData'); + + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + describe('success', () => { + beforeEach(() => { + mock.onGet(/(.*)/).replyOnce(200, 'raw'); + }); + + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', { path: tmpFile.path }) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }) + .catch(done.fail); + }); + + it('updates file raw data', done => { + store + .dispatch('getRawFileData', { path: tmpFile.path }) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }) + .catch(done.fail); + }); + + it('calls also getBaseRawFileData service method', done => { + jest.spyOn(service, 'getBaseRawFileData').mockReturnValue(Promise.resolve('baseraw')); + + store.state.currentProjectId = 'gitlab-org/gitlab-ce'; + store.state.currentMergeRequestId = '1'; + store.state.projects = { + 'gitlab-org/gitlab-ce': { + mergeRequests: { + 1: { + baseCommitSha: 'SHA', + }, + }, + }, + }; + + tmpFile.mrChange = { new_file: false }; + + store + .dispatch('getRawFileData', { path: tmpFile.path }) + .then(() => { + expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); + expect(tmpFile.baseRaw).toBe('baseraw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('return JSON', () => { + beforeEach(() => { + mock.onGet(/(.*)/).replyOnce(200, JSON.stringify({ test: '123' })); + }); + + it('does not parse returned JSON', done => { + store + .dispatch('getRawFileData', { path: tmpFile.path }) + .then(() => { + expect(tmpFile.raw).toEqual('{"test":"123"}'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/(.*)/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jest.fn(); + + actions + .getRawFileData({ state: store.state, commit() {}, dispatch }, { path: tmpFile.path }) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred whilst loading the file content.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + path: tmpFile.path, + }, + }); + + done(); + }); + }); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file('tmpFile'); + tmpFile.content = '\n'; + tmpFile.raw = '\n'; + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content\n', + }) + .then(() => { + expect(tmpFile.content).toBe('content\n'); + + done(); + }) + .catch(done.fail); + }); + + it('adds a newline to the end of the file if it doesnt already exist', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content\n'); + + done(); + }) + .catch(done.fail); + }); + + it('adds file into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file once into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array if not changed', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content\n', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: '\n', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('bursts unused seal', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.unusedSeal).toBe(false); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardFileChanges', () => { + let tmpFile; + + beforeEach(() => { + jest.spyOn(eventHub, '$on').mockImplementation(() => {}); + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + tmpFile = file(); + tmpFile.content = 'testing'; + + store.state.changedFiles.push(tmpFile); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('resets file content', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.content).not.toBe('testing'); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes temp file', done => { + tmpFile.tempFile = true; + tmpFile.opened = true; + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('does not re-open a closed temp file', done => { + tmpFile.tempFile = true; + + expect(tmpFile.opened).toBeFalsy(); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('pushes route for active file', done => { + tmpFile.active = true; + store.state.openFiles.push(tmpFile); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${tmpFile.url}`); + + done(); + }) + .catch(done.fail); + }); + + it('emits eventHub event to dispose cached model', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('stageChange', () => { + it('calls STAGE_CHANGE with file path', done => { + testAction( + actions.stageChange, + 'path', + store.state, + [ + { type: types.STAGE_CHANGE, payload: 'path' }, + { type: types.SET_LAST_COMMIT_MSG, payload: '' }, + ], + [], + done, + ); + }); + }); + + describe('unstageChange', () => { + it('calls UNSTAGE_CHANGE with file path', done => { + testAction( + actions.unstageChange, + 'path', + store.state, + [{ type: types.UNSTAGE_CHANGE, payload: 'path' }], + [], + done, + ); + }); + }); + + describe('openPendingTab', () => { + let f; + + beforeEach(() => { + f = { + ...file(), + projectId: '123', + }; + + store.state.entries[f.path] = f; + }); + + it('makes file pending in openFiles', done => { + store + .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) + .then(() => { + expect(store.state.openFiles[0].pending).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + + it('returns true when opened', done => { + store + .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) + .then(added => { + expect(added).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + + it('returns false when already opened', done => { + store.state.openFiles.push({ + ...f, + active: true, + key: `pending-${f.key}`, + }); + + store + .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) + .then(added => { + expect(added).toBe(false); + }) + .then(done) + .catch(done.fail); + }); + + it('pushes router URL when added', done => { + store.state.currentBranchId = 'master'; + + store + .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) + .then(() => { + expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('removePendingTab', () => { + let f; + + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + f = { + ...file('pendingFile'), + pending: true, + }; + }); + + it('removes pending file from open files', done => { + store.state.openFiles.push(f); + + store + .dispatch('removePendingTab', f) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('emits event to dispose model', done => { + store + .dispatch('removePendingTab', f) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.${f.key}`); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('triggerFilesChange', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('emits event that files have changed', done => { + store + .dispatch('triggerFilesChange') + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/init_image_diff_spec.js b/spec/frontend/image_diff/helpers/init_image_diff_spec.js new file mode 100644 index 00000000000..dc872ace265 --- /dev/null +++ b/spec/frontend/image_diff/helpers/init_image_diff_spec.js @@ -0,0 +1,52 @@ +import initImageDiffHelper from '~/image_diff/helpers/init_image_diff'; +import ImageDiff from '~/image_diff/image_diff'; +import ReplacedImageDiff from '~/image_diff/replaced_image_diff'; + +describe('initImageDiff', () => { + let glCache; + let fileEl; + + beforeEach(() => { + window.gl = window.gl || (window.gl = {}); + glCache = window.gl; + fileEl = document.createElement('div'); + fileEl.innerHTML = ` + <div class="diff-file"></div> + `; + + jest.spyOn(ReplacedImageDiff.prototype, 'init').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'init').mockImplementation(() => {}); + }); + + afterEach(() => { + window.gl = glCache; + }); + + it('should initialize ImageDiff if js-single-image', () => { + const diffFileEl = fileEl.querySelector('.diff-file'); + diffFileEl.innerHTML = ` + <div class="js-single-image"> + </div> + `; + + const imageDiff = initImageDiffHelper.initImageDiff(fileEl, true, false); + + expect(ImageDiff.prototype.init).toHaveBeenCalled(); + expect(imageDiff.canCreateNote).toEqual(true); + expect(imageDiff.renderCommentBadge).toEqual(false); + }); + + it('should initialize ReplacedImageDiff if js-replaced-image', () => { + const diffFileEl = fileEl.querySelector('.diff-file'); + diffFileEl.innerHTML = ` + <div class="js-replaced-image"> + </div> + `; + + const replacedImageDiff = initImageDiffHelper.initImageDiff(fileEl, false, true); + + expect(ReplacedImageDiff.prototype.init).toHaveBeenCalled(); + expect(replacedImageDiff.canCreateNote).toEqual(false); + expect(replacedImageDiff.renderCommentBadge).toEqual(true); + }); +}); diff --git a/spec/frontend/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js new file mode 100644 index 00000000000..f459fdf5a08 --- /dev/null +++ b/spec/frontend/image_diff/init_discussion_tab_spec.js @@ -0,0 +1,42 @@ +import initDiscussionTab from '~/image_diff/init_discussion_tab'; +import initImageDiffHelper from '~/image_diff/helpers/init_image_diff'; + +describe('initDiscussionTab', () => { + beforeEach(() => { + setFixtures(` + <div class="timeline-content"> + <div class="diff-file js-image-file"></div> + <div class="diff-file js-image-file"></div> + </div> + `); + }); + + it('should pass canCreateNote as false to initImageDiff', done => { + jest + .spyOn(initImageDiffHelper, 'initImageDiff') + .mockImplementation((diffFileEl, canCreateNote) => { + expect(canCreateNote).toEqual(false); + done(); + }); + + initDiscussionTab(); + }); + + it('should pass renderCommentBadge as true to initImageDiff', done => { + jest + .spyOn(initImageDiffHelper, 'initImageDiff') + .mockImplementation((diffFileEl, canCreateNote, renderCommentBadge) => { + expect(renderCommentBadge).toEqual(true); + done(); + }); + + initDiscussionTab(); + }); + + it('should call initImageDiff for each diffFileEls', () => { + jest.spyOn(initImageDiffHelper, 'initImageDiff').mockImplementation(() => {}); + initDiscussionTab(); + + expect(initImageDiffHelper.initImageDiff.mock.calls.length).toEqual(2); + }); +}); diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js new file mode 100644 index 00000000000..b0c1894058e --- /dev/null +++ b/spec/frontend/issue_show/components/edit_actions_spec.js @@ -0,0 +1,134 @@ +import Vue from 'vue'; +import editActions from '~/issue_show/components/edit_actions.vue'; +import eventHub from '~/issue_show/event_hub'; +import Store from '~/issue_show/stores'; + +describe('Edit Actions components', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(editActions); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm = new Component({ + propsData: { + canDestroy: true, + formState: store.formState, + issuableType: 'issue', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders all buttons as enabled', () => { + expect(vm.$el.querySelectorAll('.disabled').length).toBe(0); + + expect(vm.$el.querySelectorAll('[disabled]').length).toBe(0); + }); + + it('does not render delete button if canUpdate is false', done => { + vm.canDestroy = false; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-danger')).toBeNull(); + + done(); + }); + }); + + it('disables submit button when title is blank', done => { + vm.formState.title = ''; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-success').getAttribute('disabled')).toBe('disabled'); + + done(); + }); + }); + + it('should not show delete button if showDeleteButton is false', done => { + vm.showDeleteButton = false; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-danger')).toBeNull(); + done(); + }); + }); + + describe('updateIssuable', () => { + it('sends update.issauble event when clicking save button', () => { + vm.$el.querySelector('.btn-success').click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); + }); + + it('shows loading icon after clicking save button', done => { + vm.$el.querySelector('.btn-success').click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-success .fa')).not.toBeNull(); + + done(); + }); + }); + + it('disabled button after clicking save button', done => { + vm.$el.querySelector('.btn-success').click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-success').getAttribute('disabled')).toBe('disabled'); + + done(); + }); + }); + }); + + describe('closeForm', () => { + it('emits close.form when clicking cancel', () => { + vm.$el.querySelector('.btn-default').click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('close.form'); + }); + }); + + describe('deleteIssuable', () => { + it('sends delete.issuable event when clicking save button', () => { + jest.spyOn(window, 'confirm').mockReturnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); + }); + + it('shows loading icon after clicking delete button', done => { + jest.spyOn(window, 'confirm').mockReturnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-danger .fa')).not.toBeNull(); + + done(); + }); + }); + + it('does no actions when confirm is false', done => { + jest.spyOn(window, 'confirm').mockReturnValue(false); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect(eventHub.$emit).not.toHaveBeenCalledWith('delete.issuable'); + + expect(vm.$el.querySelector('.btn-danger .fa')).toBeNull(); + + done(); + }); + }); + }); +}); diff --git a/spec/frontend/issue_show/components/fields/description_spec.js b/spec/frontend/issue_show/components/fields/description_spec.js new file mode 100644 index 00000000000..8ea326ad1ee --- /dev/null +++ b/spec/frontend/issue_show/components/fields/description_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; +import eventHub from '~/issue_show/event_hub'; +import Store from '~/issue_show/stores'; +import descriptionField from '~/issue_show/components/fields/description.vue'; +import { keyboardDownEvent } from '../../helpers'; + +describe('Description field component', () => { + let vm; + let store; + + beforeEach(done => { + const Component = Vue.extend(descriptionField); + const el = document.createElement('div'); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.description = 'test'; + + document.body.appendChild(el); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm = new Component({ + el, + propsData: { + markdownPreviewPath: '/', + markdownDocsPath: '/', + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown field with description', () => { + expect(vm.$el.querySelector('.md-area textarea').value).toBe('test'); + }); + + it('renders markdown field with a markdown description', done => { + store.formState.description = '**test**'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.md-area textarea').value).toBe('**test**'); + + done(); + }); + }); + + it('focuses field when mounted', () => { + expect(document.activeElement).toBe(vm.$refs.textarea); + }); + + it('triggers update with meta+enter', () => { + vm.$el.querySelector('.md-area textarea').dispatchEvent(keyboardDownEvent(13, true)); + + expect(eventHub.$emit).toHaveBeenCalled(); + }); + + it('triggers update with ctrl+enter', () => { + vm.$el.querySelector('.md-area textarea').dispatchEvent(keyboardDownEvent(13, false, true)); + + expect(eventHub.$emit).toHaveBeenCalled(); + }); + + it('has a ref named `textarea`', () => { + expect(vm.$refs.textarea).not.toBeNull(); + }); +}); diff --git a/spec/frontend/issue_show/components/fields/title_spec.js b/spec/frontend/issue_show/components/fields/title_spec.js new file mode 100644 index 00000000000..99e8658b89f --- /dev/null +++ b/spec/frontend/issue_show/components/fields/title_spec.js @@ -0,0 +1,48 @@ +import Vue from 'vue'; +import eventHub from '~/issue_show/event_hub'; +import Store from '~/issue_show/stores'; +import titleField from '~/issue_show/components/fields/title.vue'; +import { keyboardDownEvent } from '../../helpers'; + +describe('Title field component', () => { + let vm; + let store; + + beforeEach(() => { + const Component = Vue.extend(titleField); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm = new Component({ + propsData: { + formState: store.formState, + }, + }).$mount(); + }); + + it('renders form control with formState title', () => { + expect(vm.$el.querySelector('.form-control').value).toBe('test'); + }); + + it('triggers update with meta+enter', () => { + vm.$el.querySelector('.form-control').dispatchEvent(keyboardDownEvent(13, true)); + + expect(eventHub.$emit).toHaveBeenCalled(); + }); + + it('triggers update with ctrl+enter', () => { + vm.$el.querySelector('.form-control').dispatchEvent(keyboardDownEvent(13, false, true)); + + expect(eventHub.$emit).toHaveBeenCalled(); + }); + + it('has a ref named `input`', () => { + expect(vm.$refs.input).not.toBeNull(); + }); +}); diff --git a/spec/frontend/issue_show/index_spec.js b/spec/frontend/issue_show/index_spec.js new file mode 100644 index 00000000000..e80d1b83c11 --- /dev/null +++ b/spec/frontend/issue_show/index_spec.js @@ -0,0 +1,19 @@ +import initIssueableApp from '~/issue_show'; + +describe('Issue show index', () => { + describe('initIssueableApp', () => { + it('should initialize app with no potential XSS attack', () => { + const d = document.createElement('div'); + d.id = 'js-issuable-app-initial-data'; + d.innerHTML = JSON.stringify({ + initialDescriptionHtml: '<img src=x onerror=alert(1)>', + }); + document.body.appendChild(d); + + const alertSpy = jest.spyOn(window, 'alert'); + initIssueableApp(); + + expect(alertSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js new file mode 100644 index 00000000000..04f20811601 --- /dev/null +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -0,0 +1,208 @@ +import Vue from 'vue'; +import component from '~/jobs/components/job_log_controllers.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Job log controllers', () => { + const Component = Vue.extend(component); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + const props = { + rawPath: '/raw', + erasePath: '/erase', + size: 511952, + isScrollTopDisabled: false, + isScrollBottomDisabled: false, + isScrollingDown: true, + isTraceSizeVisible: true, + }; + + describe('Truncate information', () => { + describe('with isTraceSizeVisible', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + it('renders size information', () => { + expect(vm.$el.querySelector('.js-truncated-info').textContent).toContain('499.95 KiB'); + }); + + it('renders link to raw trace', () => { + expect(vm.$el.querySelector('.js-raw-link').getAttribute('href')).toEqual('/raw'); + }); + }); + }); + + describe('links section', () => { + describe('with raw trace path', () => { + it('renders raw trace link', () => { + vm = mountComponent(Component, props); + + expect(vm.$el.querySelector('.js-raw-link-controller').getAttribute('href')).toEqual( + '/raw', + ); + }); + }); + + describe('without raw trace path', () => { + it('does not render raw trace link', () => { + vm = mountComponent(Component, { + erasePath: '/erase', + size: 511952, + isScrollTopDisabled: true, + isScrollBottomDisabled: true, + isScrollingDown: false, + isTraceSizeVisible: true, + }); + + expect(vm.$el.querySelector('.js-raw-link-controller')).toBeNull(); + }); + }); + + describe('when is erasable', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + it('renders erase job link', () => { + expect(vm.$el.querySelector('.js-erase-link')).not.toBeNull(); + }); + }); + + describe('when it is not erasable', () => { + it('does not render erase button', () => { + vm = mountComponent(Component, { + rawPath: '/raw', + size: 511952, + isScrollTopDisabled: true, + isScrollBottomDisabled: true, + isScrollingDown: false, + isTraceSizeVisible: true, + }); + + expect(vm.$el.querySelector('.js-erase-link')).toBeNull(); + }); + }); + }); + + describe('scroll buttons', () => { + describe('scroll top button', () => { + describe('when user can scroll top', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + it('renders enabled scroll top button', () => { + expect(vm.$el.querySelector('.js-scroll-top').getAttribute('disabled')).toBeNull(); + }); + + it('emits scrollJobLogTop event on click', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-scroll-top').click(); + + expect(vm.$emit).toHaveBeenCalledWith('scrollJobLogTop'); + }); + }); + + describe('when user can not scroll top', () => { + beforeEach(() => { + vm = mountComponent(Component, { + rawPath: '/raw', + erasePath: '/erase', + size: 511952, + isScrollTopDisabled: true, + isScrollBottomDisabled: false, + isScrollingDown: false, + isTraceSizeVisible: true, + }); + }); + + it('renders disabled scroll top button', () => { + expect(vm.$el.querySelector('.js-scroll-top').getAttribute('disabled')).toEqual( + 'disabled', + ); + }); + + it('does not emit scrollJobLogTop event on click', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-scroll-top').click(); + + expect(vm.$emit).not.toHaveBeenCalledWith('scrollJobLogTop'); + }); + }); + }); + + describe('scroll bottom button', () => { + describe('when user can scroll bottom', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + it('renders enabled scroll bottom button', () => { + expect(vm.$el.querySelector('.js-scroll-bottom').getAttribute('disabled')).toBeNull(); + }); + + it('emits scrollJobLogBottom event on click', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-scroll-bottom').click(); + + expect(vm.$emit).toHaveBeenCalledWith('scrollJobLogBottom'); + }); + }); + + describe('when user can not scroll bottom', () => { + beforeEach(() => { + vm = mountComponent(Component, { + rawPath: '/raw', + erasePath: '/erase', + size: 511952, + isScrollTopDisabled: false, + isScrollBottomDisabled: true, + isScrollingDown: false, + isTraceSizeVisible: true, + }); + }); + + it('renders disabled scroll bottom button', () => { + expect(vm.$el.querySelector('.js-scroll-bottom').getAttribute('disabled')).toEqual( + 'disabled', + ); + }); + + it('does not emit scrollJobLogBottom event on click', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-scroll-bottom').click(); + + expect(vm.$emit).not.toHaveBeenCalledWith('scrollJobLogBottom'); + }); + }); + + describe('while isScrollingDown is true', () => { + it('renders animate class for the scroll down button', () => { + vm = mountComponent(Component, props); + + expect(vm.$el.querySelector('.js-scroll-bottom').className).toContain('animate'); + }); + }); + + describe('while isScrollingDown is false', () => { + it('does not render animate class for the scroll down button', () => { + vm = mountComponent(Component, { + rawPath: '/raw', + erasePath: '/erase', + size: 511952, + isScrollTopDisabled: true, + isScrollBottomDisabled: false, + isScrollingDown: false, + isTraceSizeVisible: true, + }); + + expect(vm.$el.querySelector('.js-scroll-bottom').className).not.toContain('animate'); + }); + }); + }); + }); +}); diff --git a/spec/frontend/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js new file mode 100644 index 00000000000..399fa950769 --- /dev/null +++ b/spec/frontend/namespace_select_spec.js @@ -0,0 +1,66 @@ +import $ from 'jquery'; +import NamespaceSelect from '~/namespace_select'; + +describe('NamespaceSelect', () => { + beforeEach(() => { + jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {}); + }); + + it('initializes glDropdown', () => { + const dropdown = document.createElement('div'); + + // eslint-disable-next-line no-new + new NamespaceSelect({ dropdown }); + + expect($.fn.glDropdown).toHaveBeenCalled(); + }); + + describe('as input', () => { + let glDropdownOptions; + + beforeEach(() => { + const dropdown = document.createElement('div'); + // eslint-disable-next-line no-new + new NamespaceSelect({ dropdown }); + [[glDropdownOptions]] = $.fn.glDropdown.mock.calls; + }); + + it('prevents click events', () => { + const dummyEvent = new Event('dummy'); + jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); + + glDropdownOptions.clicked({ e: dummyEvent }); + + expect(dummyEvent.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('as filter', () => { + let glDropdownOptions; + + beforeEach(() => { + const dropdown = document.createElement('div'); + dropdown.dataset.isFilter = 'true'; + // eslint-disable-next-line no-new + new NamespaceSelect({ dropdown }); + [[glDropdownOptions]] = $.fn.glDropdown.mock.calls; + }); + + it('does not prevent click events', () => { + const dummyEvent = new Event('dummy'); + jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); + + glDropdownOptions.clicked({ e: dummyEvent }); + + expect(dummyEvent.preventDefault).not.toHaveBeenCalled(); + }); + + it('sets URL of dropdown items', () => { + const dummyNamespace = { id: 'eal' }; + + const itemUrl = glDropdownOptions.url(dummyNamespace); + + expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`); + }); + }); +}); diff --git a/spec/frontend/new_branch_spec.js b/spec/frontend/new_branch_spec.js new file mode 100644 index 00000000000..cff7ec1a9ee --- /dev/null +++ b/spec/frontend/new_branch_spec.js @@ -0,0 +1,203 @@ +import $ from 'jquery'; +import NewBranchForm from '~/new_branch_form'; + +describe('Branch', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('create a new branch', () => { + preloadFixtures('branches/new_branch.html'); + + function fillNameWith(value) { + $('.js-branch-name') + .val(value) + .trigger('blur'); + } + + function expectToHaveError(error) { + expect($('.js-branch-name-error span').text()).toEqual(error); + } + + beforeEach(() => { + loadFixtures('branches/new_branch.html'); + $('form').on('submit', e => e.preventDefault()); + testContext.form = new NewBranchForm($('.js-create-branch-form'), []); + }); + + it("can't start with a dot", () => { + fillNameWith('.foo'); + expectToHaveError("can't start with '.'"); + }); + + it("can't start with a slash", () => { + fillNameWith('/foo'); + expectToHaveError("can't start with '/'"); + }); + + it("can't have two consecutive dots", () => { + fillNameWith('foo..bar'); + expectToHaveError("can't contain '..'"); + }); + + it("can't have spaces anywhere", () => { + fillNameWith(' foo'); + expectToHaveError("can't contain spaces"); + fillNameWith('foo bar'); + expectToHaveError("can't contain spaces"); + fillNameWith('foo '); + expectToHaveError("can't contain spaces"); + }); + + it("can't have ~ anywhere", () => { + fillNameWith('~foo'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~bar'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~'); + expectToHaveError("can't contain '~'"); + }); + + it("can't have tilde anwhere", () => { + fillNameWith('~foo'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~bar'); + expectToHaveError("can't contain '~'"); + fillNameWith('foo~'); + expectToHaveError("can't contain '~'"); + }); + + it("can't have caret anywhere", () => { + fillNameWith('^foo'); + expectToHaveError("can't contain '^'"); + fillNameWith('foo^bar'); + expectToHaveError("can't contain '^'"); + fillNameWith('foo^'); + expectToHaveError("can't contain '^'"); + }); + + it("can't have : anywhere", () => { + fillNameWith(':foo'); + expectToHaveError("can't contain ':'"); + fillNameWith('foo:bar'); + expectToHaveError("can't contain ':'"); + fillNameWith(':foo'); + expectToHaveError("can't contain ':'"); + }); + + it("can't have question mark anywhere", () => { + fillNameWith('?foo'); + expectToHaveError("can't contain '?'"); + fillNameWith('foo?bar'); + expectToHaveError("can't contain '?'"); + fillNameWith('foo?'); + expectToHaveError("can't contain '?'"); + }); + + it("can't have asterisk anywhere", () => { + fillNameWith('*foo'); + expectToHaveError("can't contain '*'"); + fillNameWith('foo*bar'); + expectToHaveError("can't contain '*'"); + fillNameWith('foo*'); + expectToHaveError("can't contain '*'"); + }); + + it("can't have open bracket anywhere", () => { + fillNameWith('[foo'); + expectToHaveError("can't contain '['"); + fillNameWith('foo[bar'); + expectToHaveError("can't contain '['"); + fillNameWith('foo['); + expectToHaveError("can't contain '['"); + }); + + it("can't have a backslash anywhere", () => { + fillNameWith('\\foo'); + expectToHaveError("can't contain '\\'"); + fillNameWith('foo\\bar'); + expectToHaveError("can't contain '\\'"); + fillNameWith('foo\\'); + expectToHaveError("can't contain '\\'"); + }); + + it("can't contain a sequence @{ anywhere", () => { + fillNameWith('@{foo'); + expectToHaveError("can't contain '@{'"); + fillNameWith('foo@{bar'); + expectToHaveError("can't contain '@{'"); + fillNameWith('foo@{'); + expectToHaveError("can't contain '@{'"); + }); + + it("can't have consecutive slashes", () => { + fillNameWith('foo//bar'); + expectToHaveError("can't contain consecutive slashes"); + }); + + it("can't end with a slash", () => { + fillNameWith('foo/'); + expectToHaveError("can't end in '/'"); + }); + + it("can't end with a dot", () => { + fillNameWith('foo.'); + expectToHaveError("can't end in '.'"); + }); + + it("can't end with .lock", () => { + fillNameWith('foo.lock'); + expectToHaveError("can't end in '.lock'"); + }); + + it("can't be the single character @", () => { + fillNameWith('@'); + expectToHaveError("can't be '@'"); + }); + + it('concatenates all error messages', () => { + fillNameWith('/foo bar?~.'); + expectToHaveError("can't start with '/', can't contain spaces, '?', '~', can't end in '.'"); + }); + + it("doesn't duplicate error messages", () => { + fillNameWith('?foo?bar?zoo?'); + expectToHaveError("can't contain '?'"); + }); + + it('removes the error message when is a valid name', () => { + fillNameWith('foo?bar'); + + expect($('.js-branch-name-error span').length).toEqual(1); + fillNameWith('foobar'); + + expect($('.js-branch-name-error span').length).toEqual(0); + }); + + it('can have dashes anywhere', () => { + fillNameWith('-foo-bar-zoo-'); + + expect($('.js-branch-name-error span').length).toEqual(0); + }); + + it('can have underscores anywhere', () => { + fillNameWith('_foo_bar_zoo_'); + + expect($('.js-branch-name-error span').length).toEqual(0); + }); + + it('can have numbers anywhere', () => { + fillNameWith('1foo2bar3zoo4'); + + expect($('.js-branch-name-error span').length).toEqual(0); + }); + + it('can be only letters', () => { + fillNameWith('foo'); + + expect($('.js-branch-name-error span').length).toEqual(0); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_filter_note_spec.js b/spec/frontend/notes/components/discussion_filter_note_spec.js new file mode 100644 index 00000000000..6b5f42a84e8 --- /dev/null +++ b/spec/frontend/notes/components/discussion_filter_note_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import DiscussionFilterNote from '~/notes/components/discussion_filter_note.vue'; +import eventHub from '~/notes/event_hub'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('DiscussionFilterNote component', () => { + let vm; + + const createComponent = () => { + const Component = Vue.extend(DiscussionFilterNote); + + return mountComponent(Component); + }; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('timelineContent', () => { + it('returns string containing instruction for switching feed type', () => { + expect(vm.timelineContent).toBe( + "You're only seeing <b>other activity</b> in the feed. To add a comment, switch to one of the following options.", + ); + }); + }); + }); + + describe('methods', () => { + describe('selectFilter', () => { + it('emits `dropdownSelect` event on `eventHub` with provided param', () => { + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + + vm.selectFilter(1); + + expect(eventHub.$emit).toHaveBeenCalledWith('dropdownSelect', 1); + }); + }); + }); + + describe('template', () => { + it('renders component container element', () => { + expect(vm.$el.classList.contains('discussion-filter-note')).toBe(true); + }); + + it('renders comment icon element', () => { + expect(vm.$el.querySelector('.timeline-icon svg use').getAttribute('xlink:href')).toContain( + 'comment', + ); + }); + + it('renders filter information note', () => { + expect(vm.$el.querySelector('.timeline-content').innerText.trim()).toContain( + "You're only seeing other activity in the feed. To add a comment, switch to one of the following options.", + ); + }); + + it('renders filter buttons', () => { + const buttonsContainerEl = vm.$el.querySelector('.discussion-filter-actions'); + + expect(buttonsContainerEl.querySelector('button:first-child').innerText.trim()).toContain( + 'Show all activity', + ); + + expect(buttonsContainerEl.querySelector('button:last-child').innerText.trim()).toContain( + 'Show comments only', + ); + }); + + it('clicking `Show all activity` button calls `selectFilter("all")` method', () => { + const showAllBtn = vm.$el.querySelector('.discussion-filter-actions button:first-child'); + jest.spyOn(vm, 'selectFilter').mockImplementation(() => {}); + + showAllBtn.dispatchEvent(new Event('click')); + + expect(vm.selectFilter).toHaveBeenCalledWith(0); + }); + + it('clicking `Show comments only` button calls `selectFilter("comments")` method', () => { + const showAllBtn = vm.$el.querySelector('.discussion-filter-actions button:last-child'); + jest.spyOn(vm, 'selectFilter').mockImplementation(() => {}); + + showAllBtn.dispatchEvent(new Event('click')); + + expect(vm.selectFilter).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js new file mode 100644 index 00000000000..9b432387654 --- /dev/null +++ b/spec/frontend/notes/components/note_header_spec.js @@ -0,0 +1,125 @@ +import Vue from 'vue'; +import noteHeader from '~/notes/components/note_header.vue'; +import createStore from '~/notes/stores'; + +describe('note_header component', () => { + let store; + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(noteHeader); + store = createStore(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('individual note', () => { + beforeEach(() => { + vm = new Component({ + store, + propsData: { + actionText: 'commented', + actionTextHtml: '', + author: { + avatar_url: null, + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', + }, + createdAt: '2017-08-02T10:51:58.559Z', + includeToggle: false, + noteId: '1394', + expanded: true, + }, + }).$mount(); + }); + + it('should render user information', () => { + expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root'); + expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual('/root'); + expect(vm.$el.querySelector('.note-header-info a').dataset.userId).toEqual('1'); + expect(vm.$el.querySelector('.note-header-info a').dataset.username).toEqual('root'); + expect(vm.$el.querySelector('.note-header-info a').classList).toContain('js-user-link'); + }); + + it('should render timestamp link', () => { + expect(vm.$el.querySelector('a[href="#note_1394"]')).toBeDefined(); + }); + + it('should not render user information when prop `author` is empty object', done => { + vm.author = {}; + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.note-header-author-name')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discussion', () => { + beforeEach(() => { + vm = new Component({ + store, + propsData: { + actionText: 'started a discussion', + actionTextHtml: '', + author: { + avatar_url: null, + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', + }, + createdAt: '2017-08-02T10:51:58.559Z', + includeToggle: true, + noteId: '1395', + expanded: true, + }, + }).$mount(); + }); + + it('should render toggle button', () => { + expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined(); + }); + + it('emits toggle event on click', done => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + + vm.$el.querySelector('.js-vue-toggle-button').click(); + + Vue.nextTick(() => { + expect(vm.$emit).toHaveBeenCalledWith('toggleHandler'); + done(); + }); + }); + + it('renders up arrow when open', done => { + vm.expanded = true; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.js-vue-toggle-button i').classList).toContain( + 'fa-chevron-up', + ); + done(); + }); + }); + + it('renders down arrow when closed', done => { + vm.expanded = false; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.js-vue-toggle-button i').classList).toContain( + 'fa-chevron-down', + ); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/notes/stores/getters_spec.js b/spec/frontend/notes/stores/getters_spec.js new file mode 100644 index 00000000000..83417bd70ef --- /dev/null +++ b/spec/frontend/notes/stores/getters_spec.js @@ -0,0 +1,388 @@ +import * as getters from '~/notes/stores/getters'; +import { + notesDataMock, + userDataMock, + noteableDataMock, + individualNote, + collapseNotesMock, + discussion1, + discussion2, + discussion3, + resolvedDiscussion1, + unresolvableDiscussion, +} from '../mock_data'; + +const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; + +// Helper function to ensure that we're using the same schema across tests. +const createDiscussionNeighborParams = (discussionId, diffOrder, step) => ({ + discussionId, + diffOrder, + step, +}); + +describe('Getters Notes Store', () => { + let state; + + preloadFixtures(discussionWithTwoUnresolvedNotes); + + beforeEach(() => { + state = { + discussions: [individualNote], + targetNoteHash: 'hash', + lastFetchedAt: 'timestamp', + isNotesFetched: false, + notesData: notesDataMock, + userData: userDataMock, + noteableData: noteableDataMock, + }; + }); + + describe('showJumpToNextDiscussion', () => { + it('should return true if there are 2 or more unresolved discussions', () => { + const localGetters = { + unresolvedDiscussionsIdsByDate: ['123', '456'], + allResolvableDiscussions: [], + }; + + expect(getters.showJumpToNextDiscussion(state, localGetters)()).toBe(true); + }); + + it('should return false if there are 1 or less unresolved discussions', () => { + const localGetters = { + unresolvedDiscussionsIdsByDate: ['123'], + allResolvableDiscussions: [], + }; + + expect(getters.showJumpToNextDiscussion(state, localGetters)()).toBe(false); + }); + }); + + describe('discussions', () => { + it('should return all discussions in the store', () => { + expect(getters.discussions(state)).toEqual([individualNote]); + }); + }); + + describe('resolvedDiscussionsById', () => { + it('ignores unresolved system notes', () => { + const [discussion] = getJSONFixture(discussionWithTwoUnresolvedNotes); + discussion.notes[0].resolved = true; + discussion.notes[1].resolved = false; + state.discussions.push(discussion); + + expect(getters.resolvedDiscussionsById(state)).toEqual({ + [discussion.id]: discussion, + }); + }); + }); + + describe('Collapsed notes', () => { + const stateCollapsedNotes = { + discussions: collapseNotesMock, + targetNoteHash: 'hash', + lastFetchedAt: 'timestamp', + + notesData: notesDataMock, + userData: userDataMock, + noteableData: noteableDataMock, + }; + + it('should return a single system note when a description was updated multiple times', () => { + expect(getters.discussions(stateCollapsedNotes).length).toEqual(1); + }); + }); + + describe('targetNoteHash', () => { + it('should return `targetNoteHash`', () => { + expect(getters.targetNoteHash(state)).toEqual('hash'); + }); + }); + + describe('getNotesData', () => { + it('should return all data in `notesData`', () => { + expect(getters.getNotesData(state)).toEqual(notesDataMock); + }); + }); + + describe('getNoteableData', () => { + it('should return all data in `noteableData`', () => { + expect(getters.getNoteableData(state)).toEqual(noteableDataMock); + }); + }); + + describe('getUserData', () => { + it('should return all data in `userData`', () => { + expect(getters.getUserData(state)).toEqual(userDataMock); + }); + }); + + describe('notesById', () => { + it('should return the note for the given id', () => { + expect(getters.notesById(state)).toEqual({ 1390: individualNote.notes[0] }); + }); + }); + + describe('getCurrentUserLastNote', () => { + it('should return the last note of the current user', () => { + expect(getters.getCurrentUserLastNote(state)).toEqual(individualNote.notes[0]); + }); + }); + + describe('openState', () => { + it('should return the issue state', () => { + expect(getters.openState(state)).toEqual(noteableDataMock.state); + }); + }); + + describe('isNotesFetched', () => { + it('should return the state for the fetching notes', () => { + expect(getters.isNotesFetched(state)).toBeFalsy(); + }); + }); + + describe('allResolvableDiscussions', () => { + it('should return only resolvable discussions in same order', () => { + state.discussions = [ + discussion3, + unresolvableDiscussion, + discussion1, + unresolvableDiscussion, + discussion2, + ]; + + expect(getters.allResolvableDiscussions(state)).toEqual([ + discussion3, + discussion1, + discussion2, + ]); + }); + + it('should return empty array if there are no resolvable discussions', () => { + state.discussions = [unresolvableDiscussion, unresolvableDiscussion]; + + expect(getters.allResolvableDiscussions(state)).toEqual([]); + }); + }); + + describe('unresolvedDiscussionsIdsByDiff', () => { + it('should return all discussions IDs in diff order', () => { + const localGetters = { + allResolvableDiscussions: [discussion3, discussion1, discussion2], + }; + + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([ + 'abc1', + 'abc2', + 'abc3', + ]); + }); + + it('should return empty array if all discussions have been resolved', () => { + const localGetters = { + allResolvableDiscussions: [resolvedDiscussion1], + }; + + expect(getters.unresolvedDiscussionsIdsByDiff(state, localGetters)).toEqual([]); + }); + }); + + describe('unresolvedDiscussionsIdsByDate', () => { + it('should return all discussions in date ascending order', () => { + const localGetters = { + allResolvableDiscussions: [discussion3, discussion1, discussion2], + }; + + expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([ + 'abc2', + 'abc1', + 'abc3', + ]); + }); + + it('should return empty array if all discussions have been resolved', () => { + const localGetters = { + allResolvableDiscussions: [resolvedDiscussion1], + }; + + expect(getters.unresolvedDiscussionsIdsByDate(state, localGetters)).toEqual([]); + }); + }); + + describe('unresolvedDiscussionsIdsOrdered', () => { + const localGetters = { + unresolvedDiscussionsIdsByDate: ['123', '456'], + unresolvedDiscussionsIdsByDiff: ['abc', 'def'], + }; + + it('should return IDs ordered by diff when diffOrder param is true', () => { + expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(true)).toEqual([ + 'abc', + 'def', + ]); + }); + + it('should return IDs ordered by date when diffOrder param is not true', () => { + expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(false)).toEqual([ + '123', + '456', + ]); + + expect(getters.unresolvedDiscussionsIdsOrdered(state, localGetters)(undefined)).toEqual([ + '123', + '456', + ]); + }); + }); + + describe('isLastUnresolvedDiscussion', () => { + const localGetters = { + unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'], + }; + + it('should return true if the discussion id provided is the last', () => { + expect(getters.isLastUnresolvedDiscussion(state, localGetters)('789')).toBe(true); + }); + + it('should return false if the discussion id provided is not the last', () => { + expect(getters.isLastUnresolvedDiscussion(state, localGetters)('123')).toBe(false); + expect(getters.isLastUnresolvedDiscussion(state, localGetters)('456')).toBe(false); + }); + }); + + describe('findUnresolvedDiscussionIdNeighbor', () => { + let localGetters; + beforeEach(() => { + localGetters = { + unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'], + }; + }); + + [ + { step: 1, id: '123', expected: '456' }, + { step: 1, id: '456', expected: '789' }, + { step: 1, id: '789', expected: '123' }, + { step: -1, id: '123', expected: '789' }, + { step: -1, id: '456', expected: '123' }, + { step: -1, id: '789', expected: '456' }, + ].forEach(({ step, id, expected }) => { + it(`with step ${step} and id ${id}, returns next value`, () => { + const params = createDiscussionNeighborParams(id, true, step); + + expect(getters.findUnresolvedDiscussionIdNeighbor(state, localGetters)(params)).toBe( + expected, + ); + }); + }); + + describe('with 1 unresolved discussion', () => { + beforeEach(() => { + localGetters = { + unresolvedDiscussionsIdsOrdered: () => ['123'], + }; + }); + + [{ step: 1, id: '123', expected: '123' }, { step: -1, id: '123', expected: '123' }].forEach( + ({ step, id, expected }) => { + it(`with step ${step} and match, returns only value`, () => { + const params = createDiscussionNeighborParams(id, true, step); + + expect(getters.findUnresolvedDiscussionIdNeighbor(state, localGetters)(params)).toBe( + expected, + ); + }); + }, + ); + + it('with no match, returns only value', () => { + const params = createDiscussionNeighborParams('bogus', true, 1); + + expect(getters.findUnresolvedDiscussionIdNeighbor(state, localGetters)(params)).toBe('123'); + }); + }); + + describe('with 0 unresolved discussions', () => { + beforeEach(() => { + localGetters = { + unresolvedDiscussionsIdsOrdered: () => [], + }; + }); + + [{ step: 1 }, { step: -1 }].forEach(({ step }) => { + it(`with step ${step}, returns undefined`, () => { + const params = createDiscussionNeighborParams('bogus', true, step); + + expect( + getters.findUnresolvedDiscussionIdNeighbor(state, localGetters)(params), + ).toBeUndefined(); + }); + }); + }); + }); + + describe('findUnresolvedDiscussionIdNeighbor aliases', () => { + let neighbor; + let findUnresolvedDiscussionIdNeighbor; + let localGetters; + + beforeEach(() => { + neighbor = {}; + findUnresolvedDiscussionIdNeighbor = jest.fn(() => neighbor); + localGetters = { findUnresolvedDiscussionIdNeighbor }; + }); + + describe('nextUnresolvedDiscussionId', () => { + it('should return result of find neighbor', () => { + const expectedParams = createDiscussionNeighborParams('123', true, 1); + const result = getters.nextUnresolvedDiscussionId(state, localGetters)('123', true); + + expect(findUnresolvedDiscussionIdNeighbor).toHaveBeenCalledWith(expectedParams); + expect(result).toBe(neighbor); + }); + }); + + describe('previosuUnresolvedDiscussionId', () => { + it('should return result of find neighbor', () => { + const expectedParams = createDiscussionNeighborParams('123', true, -1); + const result = getters.previousUnresolvedDiscussionId(state, localGetters)('123', true); + + expect(findUnresolvedDiscussionIdNeighbor).toHaveBeenCalledWith(expectedParams); + expect(result).toBe(neighbor); + }); + }); + }); + + describe('firstUnresolvedDiscussionId', () => { + const localGetters = { + unresolvedDiscussionsIdsByDate: ['123', '456'], + unresolvedDiscussionsIdsByDiff: ['abc', 'def'], + }; + + it('should return the first discussion id by diff when diffOrder param is true', () => { + expect(getters.firstUnresolvedDiscussionId(state, localGetters)(true)).toBe('abc'); + }); + + it('should return the first discussion id by date when diffOrder param is not true', () => { + expect(getters.firstUnresolvedDiscussionId(state, localGetters)(false)).toBe('123'); + expect(getters.firstUnresolvedDiscussionId(state, localGetters)(undefined)).toBe('123'); + }); + + it('should be falsy if all discussions are resolved', () => { + const localGettersFalsy = { + unresolvedDiscussionsIdsByDiff: [], + unresolvedDiscussionsIdsByDate: [], + }; + + expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(true)).toBeFalsy(); + expect(getters.firstUnresolvedDiscussionId(state, localGettersFalsy)(false)).toBeFalsy(); + }); + }); + + describe('getDiscussion', () => { + it('returns discussion by ID', () => { + state.discussions.push({ id: '1' }); + + expect(getters.getDiscussion(state)('1')).toEqual({ id: '1' }); + }); + }); +}); diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js new file mode 100644 index 00000000000..49debe348e2 --- /dev/null +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -0,0 +1,584 @@ +import Vue from 'vue'; +import mutations from '~/notes/stores/mutations'; +import { DISCUSSION_NOTE } from '~/notes/constants'; +import { + note, + discussionMock, + notesDataMock, + userDataMock, + noteableDataMock, + individualNote, +} from '../mock_data'; + +const RESOLVED_NOTE = { resolvable: true, resolved: true }; +const UNRESOLVED_NOTE = { resolvable: true, resolved: false }; +const SYSTEM_NOTE = { resolvable: false, resolved: false }; +const WEIRD_NOTE = { resolvable: false, resolved: true }; + +describe('Notes Store mutations', () => { + describe('ADD_NEW_NOTE', () => { + let state; + let noteData; + + beforeEach(() => { + state = { discussions: [] }; + noteData = { + expanded: true, + id: note.discussion_id, + individual_note: true, + notes: [note], + reply_id: note.discussion_id, + }; + mutations.ADD_NEW_NOTE(state, note); + }); + + it('should add a new note to an array of notes', () => { + expect(state).toEqual({ + discussions: [noteData], + }); + + expect(state.discussions.length).toBe(1); + }); + + it('should not add the same note to the notes array', () => { + mutations.ADD_NEW_NOTE(state, note); + + expect(state.discussions.length).toBe(1); + }); + }); + + describe('ADD_NEW_REPLY_TO_DISCUSSION', () => { + const newReply = Object.assign({}, note, { discussion_id: discussionMock.id }); + + let state; + + beforeEach(() => { + state = { discussions: [{ ...discussionMock }] }; + }); + + it('should add a reply to a specific discussion', () => { + mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply); + + expect(state.discussions[0].notes.length).toEqual(4); + }); + + it('should not add the note if it already exists in the discussion', () => { + mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply); + mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply); + + expect(state.discussions[0].notes.length).toEqual(4); + }); + }); + + describe('DELETE_NOTE', () => { + it('should delete a note ', () => { + const state = { discussions: [discussionMock] }; + const toDelete = discussionMock.notes[0]; + const lengthBefore = discussionMock.notes.length; + + mutations.DELETE_NOTE(state, toDelete); + + expect(state.discussions[0].notes.length).toEqual(lengthBefore - 1); + }); + }); + + describe('EXPAND_DISCUSSION', () => { + it('should expand a collapsed discussion', () => { + const discussion = Object.assign({}, discussionMock, { expanded: false }); + + const state = { + discussions: [discussion], + }; + + mutations.EXPAND_DISCUSSION(state, { discussionId: discussion.id }); + + expect(state.discussions[0].expanded).toEqual(true); + }); + }); + + describe('COLLAPSE_DISCUSSION', () => { + it('should collapse an expanded discussion', () => { + const discussion = Object.assign({}, discussionMock, { expanded: true }); + + const state = { + discussions: [discussion], + }; + + mutations.COLLAPSE_DISCUSSION(state, { discussionId: discussion.id }); + + expect(state.discussions[0].expanded).toEqual(false); + }); + }); + + describe('REMOVE_PLACEHOLDER_NOTES', () => { + it('should remove all placeholder notes in indivudal notes and discussion', () => { + const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true }); + const state = { discussions: [placeholderNote] }; + mutations.REMOVE_PLACEHOLDER_NOTES(state); + + expect(state.discussions).toEqual([]); + }); + }); + + describe('SET_NOTES_DATA', () => { + it('should set an object with notesData', () => { + const state = { + notesData: {}, + }; + + mutations.SET_NOTES_DATA(state, notesDataMock); + + expect(state.notesData).toEqual(notesDataMock); + }); + }); + + describe('SET_NOTEABLE_DATA', () => { + it('should set the issue data', () => { + const state = { + noteableData: {}, + }; + + mutations.SET_NOTEABLE_DATA(state, noteableDataMock); + + expect(state.noteableData).toEqual(noteableDataMock); + }); + }); + + describe('SET_USER_DATA', () => { + it('should set the user data', () => { + const state = { + userData: {}, + }; + + mutations.SET_USER_DATA(state, userDataMock); + + expect(state.userData).toEqual(userDataMock); + }); + }); + + describe('SET_INITIAL_DISCUSSIONS', () => { + it('should set the initial notes received', () => { + const state = { + discussions: [], + }; + const legacyNote = { + id: 2, + individual_note: true, + notes: [ + { + note: '1', + }, + { + note: '2', + }, + ], + }; + + mutations.SET_INITIAL_DISCUSSIONS(state, [note, legacyNote]); + + expect(state.discussions[0].id).toEqual(note.id); + expect(state.discussions[1].notes[0].note).toBe(legacyNote.notes[0].note); + expect(state.discussions[2].notes[0].note).toBe(legacyNote.notes[1].note); + expect(state.discussions.length).toEqual(3); + }); + + it('adds truncated_diff_lines if discussion is a diffFile', () => { + const state = { + discussions: [], + }; + + mutations.SET_INITIAL_DISCUSSIONS(state, [ + { + ...note, + diff_file: { + file_hash: 'a', + }, + truncated_diff_lines: [{ text: '+a', rich_text: '+<span>a</span>' }], + }, + ]); + + expect(state.discussions[0].truncated_diff_lines).toEqual([{ rich_text: '<span>a</span>' }]); + }); + + it('adds empty truncated_diff_lines when not in discussion', () => { + const state = { + discussions: [], + }; + + mutations.SET_INITIAL_DISCUSSIONS(state, [ + { + ...note, + diff_file: { + file_hash: 'a', + }, + }, + ]); + + expect(state.discussions[0].truncated_diff_lines).toEqual([]); + }); + }); + + describe('SET_LAST_FETCHED_AT', () => { + it('should set timestamp', () => { + const state = { + lastFetchedAt: [], + }; + + mutations.SET_LAST_FETCHED_AT(state, 'timestamp'); + + expect(state.lastFetchedAt).toEqual('timestamp'); + }); + }); + + describe('SET_TARGET_NOTE_HASH', () => { + it('should set the note hash', () => { + const state = { + targetNoteHash: [], + }; + + mutations.SET_TARGET_NOTE_HASH(state, 'hash'); + + expect(state.targetNoteHash).toEqual('hash'); + }); + }); + + describe('SHOW_PLACEHOLDER_NOTE', () => { + it('should set a placeholder note', () => { + const state = { + discussions: [], + }; + mutations.SHOW_PLACEHOLDER_NOTE(state, note); + + expect(state.discussions[0].isPlaceholderNote).toEqual(true); + }); + }); + + describe('TOGGLE_AWARD', () => { + it('should add award if user has not reacted yet', () => { + const state = { + discussions: [note], + userData: userDataMock, + }; + + const data = { + note, + awardName: 'cartwheel', + }; + + mutations.TOGGLE_AWARD(state, data); + const lastIndex = state.discussions[0].award_emoji.length - 1; + + expect(state.discussions[0].award_emoji[lastIndex]).toEqual({ + name: 'cartwheel', + user: { id: userDataMock.id, name: userDataMock.name, username: userDataMock.username }, + }); + }); + + it('should remove award if user already reacted', () => { + const state = { + discussions: [note], + userData: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }; + + const data = { + note, + awardName: 'bath_tone3', + }; + mutations.TOGGLE_AWARD(state, data); + + expect(state.discussions[0].award_emoji.length).toEqual(2); + }); + }); + + describe('TOGGLE_DISCUSSION', () => { + it('should open a closed discussion', () => { + const discussion = Object.assign({}, discussionMock, { expanded: false }); + + const state = { + discussions: [discussion], + }; + + mutations.TOGGLE_DISCUSSION(state, { discussionId: discussion.id }); + + expect(state.discussions[0].expanded).toEqual(true); + }); + + it('should close a opened discussion', () => { + const state = { + discussions: [discussionMock], + }; + + mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id }); + + expect(state.discussions[0].expanded).toEqual(false); + }); + + it('forces a discussions expanded state', () => { + const state = { + discussions: [{ ...discussionMock, expanded: false }], + }; + + mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id, forceExpanded: true }); + + expect(state.discussions[0].expanded).toEqual(true); + }); + }); + + describe('UPDATE_NOTE', () => { + it('should update a note', () => { + const state = { + discussions: [individualNote], + }; + + const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' }); + + mutations.UPDATE_NOTE(state, updated); + + expect(state.discussions[0].notes[0].note).toEqual('Foo'); + }); + + it('transforms an individual note to discussion', () => { + const state = { + discussions: [individualNote], + }; + + const transformedNote = { ...individualNote.notes[0], type: DISCUSSION_NOTE }; + + mutations.UPDATE_NOTE(state, transformedNote); + + expect(state.discussions[0].individual_note).toEqual(false); + }); + }); + + describe('CLOSE_ISSUE', () => { + it('should set issue as closed', () => { + const state = { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.CLOSE_ISSUE(state); + + expect(state.noteableData.state).toEqual('closed'); + }); + }); + + describe('REOPEN_ISSUE', () => { + it('should set issue as closed', () => { + const state = { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.REOPEN_ISSUE(state); + + expect(state.noteableData.state).toEqual('reopened'); + }); + }); + + describe('TOGGLE_STATE_BUTTON_LOADING', () => { + it('should set isToggleStateButtonLoading as true', () => { + const state = { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_STATE_BUTTON_LOADING(state, true); + + expect(state.isToggleStateButtonLoading).toEqual(true); + }); + + it('should set isToggleStateButtonLoading as false', () => { + const state = { + discussions: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: true, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_STATE_BUTTON_LOADING(state, false); + + expect(state.isToggleStateButtonLoading).toEqual(false); + }); + }); + + describe('SET_NOTES_FETCHED_STATE', () => { + it('should set the given state', () => { + const state = { + isNotesFetched: false, + }; + + mutations.SET_NOTES_FETCHED_STATE(state, true); + + expect(state.isNotesFetched).toEqual(true); + }); + }); + + describe('SET_DISCUSSION_DIFF_LINES', () => { + it('sets truncated_diff_lines', () => { + const state = { + discussions: [ + { + id: 1, + }, + ], + }; + + mutations.SET_DISCUSSION_DIFF_LINES(state, { + discussionId: 1, + diffLines: [{ text: '+a', rich_text: '+<span>a</span>' }], + }); + + expect(state.discussions[0].truncated_diff_lines).toEqual([{ rich_text: '<span>a</span>' }]); + }); + + it('keeps reactivity of discussion', () => { + const state = {}; + Vue.set(state, 'discussions', [ + { + id: 1, + expanded: false, + }, + ]); + const discussion = state.discussions[0]; + + mutations.SET_DISCUSSION_DIFF_LINES(state, { + discussionId: 1, + diffLines: [{ rich_text: '<span>a</span>' }], + }); + + discussion.expanded = true; + + expect(state.discussions[0].expanded).toBe(true); + }); + }); + + describe('DISABLE_COMMENTS', () => { + it('should set comments disabled state', () => { + const state = {}; + + mutations.DISABLE_COMMENTS(state, true); + + expect(state.commentsDisabled).toEqual(true); + }); + }); + + describe('UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => { + it('with unresolvable discussions, updates state', () => { + const state = { + discussions: [ + { individual_note: false, resolvable: true, notes: [UNRESOLVED_NOTE] }, + { individual_note: true, resolvable: true, notes: [UNRESOLVED_NOTE] }, + { individual_note: false, resolvable: false, notes: [UNRESOLVED_NOTE] }, + ], + }; + + mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); + + expect(state).toEqual( + expect.objectContaining({ + resolvableDiscussionsCount: 1, + unresolvedDiscussionsCount: 1, + hasUnresolvedDiscussions: false, + }), + ); + }); + + it('with resolvable discussions, updates state', () => { + const state = { + discussions: [ + { + individual_note: false, + resolvable: true, + notes: [RESOLVED_NOTE, SYSTEM_NOTE, RESOLVED_NOTE], + }, + { + individual_note: false, + resolvable: true, + notes: [RESOLVED_NOTE, SYSTEM_NOTE, WEIRD_NOTE], + }, + { + individual_note: false, + resolvable: true, + notes: [SYSTEM_NOTE, RESOLVED_NOTE, WEIRD_NOTE, UNRESOLVED_NOTE], + }, + { + individual_note: false, + resolvable: true, + notes: [UNRESOLVED_NOTE], + }, + ], + }; + + mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state); + + expect(state).toEqual( + expect.objectContaining({ + resolvableDiscussionsCount: 4, + unresolvedDiscussionsCount: 2, + hasUnresolvedDiscussions: true, + }), + ); + }); + }); + + describe('CONVERT_TO_DISCUSSION', () => { + let discussion; + let state; + + beforeEach(() => { + discussion = { + id: 42, + individual_note: true, + }; + state = { convertedDisscussionIds: [] }; + }); + + it('adds a discussion to convertedDisscussionIds', () => { + mutations.CONVERT_TO_DISCUSSION(state, discussion.id); + + expect(state.convertedDisscussionIds).toContain(discussion.id); + }); + }); + + describe('REMOVE_CONVERTED_DISCUSSION', () => { + let discussion; + let state; + + beforeEach(() => { + discussion = { + id: 42, + individual_note: true, + }; + state = { convertedDisscussionIds: [41, 42] }; + }); + + it('removes a discussion from convertedDisscussionIds', () => { + mutations.REMOVE_CONVERTED_DISCUSSION(state, discussion.id); + + expect(state.convertedDisscussionIds).not.toContain(discussion.id); + }); + }); +}); diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js new file mode 100644 index 00000000000..8917251d285 --- /dev/null +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -0,0 +1,244 @@ +import $ from 'jquery'; +import GLDropdown from '~/gl_dropdown'; // eslint-disable-line no-unused-vars +import TimezoneDropdown, { + formatUtcOffset, + formatTimezone, + findTimezoneByIdentifier, +} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; + +describe('Timezone Dropdown', () => { + preloadFixtures('pipeline_schedules/edit.html'); + + let $inputEl = null; + let $dropdownEl = null; + let $wrapper = null; + const tzListSel = '.dropdown-content ul li a.is-active'; + const tzDropdownToggleText = '.dropdown-toggle-text'; + + describe('Initialize', () => { + describe('with dropdown already loaded', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit.html'); + $wrapper = $('.dropdown'); + $inputEl = $('#schedule_cron_timezone'); + $dropdownEl = $('.js-timezone-dropdown'); + + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + }); + }); + + it('can take an $inputEl in the constructor', () => { + const tzStr = '[UTC + 5.5] Sri Jayawardenepura'; + const tzValue = 'Asia/Colombo'; + + expect($inputEl.val()).toBe('UTC'); + + $(`${tzListSel}:contains('${tzStr}')`, $wrapper).trigger('click'); + + const val = $inputEl.val(); + + expect(val).toBe(tzValue); + expect(val).not.toBe('UTC'); + }); + + it('will format data array of timezones into a list of offsets', () => { + const data = $dropdownEl.data('data'); + const formatted = $wrapper.find(tzListSel).text(); + + data.forEach(item => { + expect(formatted).toContain(formatTimezone(item)); + }); + }); + + it('will default the timezone to UTC', () => { + const tz = $inputEl.val(); + + expect(tz).toBe('UTC'); + }); + }); + + describe('without dropdown loaded', () => { + beforeEach(() => { + loadFixtures('pipeline_schedules/edit.html'); + $wrapper = $('.dropdown'); + $inputEl = $('#schedule_cron_timezone'); + $dropdownEl = $('.js-timezone-dropdown'); + }); + + it('will populate the list of UTC offsets after the dropdown is loaded', () => { + expect($wrapper.find(tzListSel).length).toEqual(0); + + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + }); + + expect($wrapper.find(tzListSel).length).toEqual($($dropdownEl).data('data').length); + }); + + it('will call a provided handler when a new timezone is selected', () => { + const onSelectTimezone = jest.fn(); + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + onSelectTimezone, + }); + + $wrapper + .find(tzListSel) + .first() + .trigger('click'); + + expect(onSelectTimezone).toHaveBeenCalled(); + }); + + it('will correctly set the dropdown label if a timezone identifier is set on the inputEl', () => { + $inputEl.val('America/St_Johns'); + + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + displayFormat: selectedItem => formatTimezone(selectedItem), + }); + + expect($wrapper.find(tzDropdownToggleText).html()).toEqual('[UTC - 2.5] Newfoundland'); + }); + + it('will call a provided `displayFormat` handler to format the dropdown value', () => { + const displayFormat = jest.fn(); + // eslint-disable-next-line no-new + new TimezoneDropdown({ + $inputEl, + $dropdownEl, + displayFormat, + }); + + $wrapper + .find(tzListSel) + .first() + .trigger('click'); + + expect(displayFormat).toHaveBeenCalled(); + }); + }); + }); + + describe('formatUtcOffset', () => { + it('will convert negative utc offsets in seconds to hours and minutes', () => { + expect(formatUtcOffset(-21600)).toEqual('- 6'); + }); + + it('will convert positive utc offsets in seconds to hours and minutes', () => { + expect(formatUtcOffset(25200)).toEqual('+ 7'); + expect(formatUtcOffset(49500)).toEqual('+ 13.75'); + }); + + it('will return 0 when given a string', () => { + expect(formatUtcOffset('BLAH')).toEqual('0'); + expect(formatUtcOffset('$%$%')).toEqual('0'); + }); + + it('will return 0 when given an array', () => { + expect(formatUtcOffset(['an', 'array'])).toEqual('0'); + }); + + it('will return 0 when given an object', () => { + expect(formatUtcOffset({ some: '', object: '' })).toEqual('0'); + }); + + it('will return 0 when given null', () => { + expect(formatUtcOffset(null)).toEqual('0'); + }); + + it('will return 0 when given undefined', () => { + expect(formatUtcOffset(undefined)).toEqual('0'); + }); + + it('will return 0 when given empty input', () => { + expect(formatUtcOffset('')).toEqual('0'); + }); + }); + + describe('formatTimezone', () => { + it('given name: "Chatham Is.", offset: "49500", will format for display as "[UTC + 13.75] Chatham Is."', () => { + expect( + formatTimezone({ + name: 'Chatham Is.', + offset: 49500, + identifier: 'Pacific/Chatham', + }), + ).toEqual('[UTC + 13.75] Chatham Is.'); + }); + + it('given name: "Saskatchewan", offset: "-21600", will format for display as "[UTC - 6] Saskatchewan"', () => { + expect( + formatTimezone({ + name: 'Saskatchewan', + offset: -21600, + identifier: 'America/Regina', + }), + ).toEqual('[UTC - 6] Saskatchewan'); + }); + + it('given name: "Accra", offset: "0", will format for display as "[UTC 0] Accra"', () => { + expect( + formatTimezone({ + name: 'Accra', + offset: 0, + identifier: 'Africa/Accra', + }), + ).toEqual('[UTC 0] Accra'); + }); + }); + + describe('findTimezoneByIdentifier', () => { + const tzList = [ + { + identifier: 'Asia/Tokyo', + name: 'Sapporo', + offset: 32400, + }, + { + identifier: 'Asia/Hong_Kong', + name: 'Hong Kong', + offset: 28800, + }, + { + identifier: 'Asia/Dhaka', + name: 'Dhaka', + offset: 21600, + }, + ]; + + const identifier = 'Asia/Dhaka'; + it('returns the correct object if the identifier exists', () => { + const res = findTimezoneByIdentifier(tzList, identifier); + + expect(res).toBeTruthy(); + expect(res).toBe(tzList[2]); + }); + + it('returns null if it doesnt find the identifier', () => { + const res = findTimezoneByIdentifier(tzList, 'Australia/Melbourne'); + + expect(res).toBeNull(); + }); + + it('returns null if there is no identifier given', () => { + expect(findTimezoneByIdentifier(tzList)).toBeNull(); + expect(findTimezoneByIdentifier(tzList, '')).toBeNull(); + }); + + it('returns null if there is an empty or invalid array given', () => { + expect(findTimezoneByIdentifier([], identifier)).toBeNull(); + expect(findTimezoneByIdentifier(null, identifier)).toBeNull(); + expect(findTimezoneByIdentifier(undefined, identifier)).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/pipelines/nav_controls_spec.js b/spec/frontend/pipelines/nav_controls_spec.js new file mode 100644 index 00000000000..6d28da0ea2a --- /dev/null +++ b/spec/frontend/pipelines/nav_controls_spec.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import navControlsComp from '~/pipelines/components/nav_controls.vue'; +import mountComponent from '../helpers/vue_mount_component_helper'; + +describe('Pipelines Nav Controls', () => { + let NavControlsComponent; + let component; + + beforeEach(() => { + NavControlsComponent = Vue.extend(navControlsComp); + }); + + afterEach(() => { + component.$destroy(); + }); + + it('should render link to create a new pipeline', () => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + component = mountComponent(NavControlsComponent, mockData); + + expect(component.$el.querySelector('.js-run-pipeline').textContent).toContain('Run Pipeline'); + expect(component.$el.querySelector('.js-run-pipeline').getAttribute('href')).toEqual( + mockData.newPipelinePath, + ); + }); + + it('should not render link to create pipeline if no path is provided', () => { + const mockData = { + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + component = mountComponent(NavControlsComponent, mockData); + + expect(component.$el.querySelector('.js-run-pipeline')).toEqual(null); + }); + + it('should render link for CI lint', () => { + const mockData = { + newPipelinePath: 'foo', + helpPagePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + component = mountComponent(NavControlsComponent, mockData); + + expect(component.$el.querySelector('.js-ci-lint').textContent.trim()).toContain('CI Lint'); + expect(component.$el.querySelector('.js-ci-lint').getAttribute('href')).toEqual( + mockData.ciLintPath, + ); + }); + + describe('Reset Runners Cache', () => { + beforeEach(() => { + const mockData = { + newPipelinePath: 'foo', + ciLintPath: 'foo', + resetCachePath: 'foo', + }; + + component = mountComponent(NavControlsComponent, mockData); + }); + + it('should render button for resetting runner caches', () => { + expect(component.$el.querySelector('.js-clear-cache').textContent.trim()).toContain( + 'Clear Runner Caches', + ); + }); + + it('should emit postAction event when reset runner cache button is clicked', () => { + jest.spyOn(component, '$emit').mockImplementation(() => {}); + + component.$el.querySelector('.js-clear-cache').click(); + + expect(component.$emit).toHaveBeenCalledWith('resetRunnersCache', 'foo'); + }); + }); +}); diff --git a/spec/frontend/polyfills/element_spec.js b/spec/frontend/polyfills/element_spec.js new file mode 100644 index 00000000000..64ce248ca44 --- /dev/null +++ b/spec/frontend/polyfills/element_spec.js @@ -0,0 +1,46 @@ +import '~/commons/polyfills/element'; + +describe('Element polyfills', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + beforeEach(() => { + testContext.element = document.createElement('ul'); + }); + + describe('matches', () => { + it('returns true if element matches the selector', () => { + expect(testContext.element.matches('ul')).toBeTruthy(); + }); + + it("returns false if element doesn't match the selector", () => { + expect(testContext.element.matches('.not-an-element')).toBeFalsy(); + }); + }); + + describe('closest', () => { + beforeEach(() => { + testContext.childElement = document.createElement('li'); + testContext.element.appendChild(testContext.childElement); + }); + + it('returns the closest parent that matches the selector', () => { + expect(testContext.childElement.closest('ul').toString()).toBe( + testContext.element.toString(), + ); + }); + + it('returns itself if it matches the selector', () => { + expect(testContext.childElement.closest('li').toString()).toBe( + testContext.childElement.toString(), + ); + }); + + it('returns undefined if nothing matches the selector', () => { + expect(testContext.childElement.closest('.no-an-element')).toBeFalsy(); + }); + }); +}); diff --git a/spec/frontend/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js new file mode 100644 index 00000000000..1fec864599c --- /dev/null +++ b/spec/frontend/profile/add_ssh_key_validation_spec.js @@ -0,0 +1,71 @@ +import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh_key_validation'; + +describe('AddSshKeyValidation', () => { + describe('submit', () => { + it('returns true if isValid is true', () => { + const addSshKeyValidation = new AddSshKeyValidation({}); + jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(true); + + expect(addSshKeyValidation.submit()).toBeTruthy(); + }); + + it('calls preventDefault and toggleWarning if isValid is false', () => { + const addSshKeyValidation = new AddSshKeyValidation({}); + const event = { + preventDefault: jest.fn(), + }; + jest.spyOn(AddSshKeyValidation, 'isPublicKey').mockReturnValue(false); + jest.spyOn(addSshKeyValidation, 'toggleWarning').mockImplementation(() => {}); + + addSshKeyValidation.submit(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(addSshKeyValidation.toggleWarning).toHaveBeenCalledWith(true); + }); + }); + + describe('toggleWarning', () => { + it('shows warningElement and hides originalSubmitElement if isVisible is true', () => { + const warningElement = document.createElement('div'); + const originalSubmitElement = document.createElement('div'); + warningElement.classList.add('hide'); + + const addSshKeyValidation = new AddSshKeyValidation( + {}, + warningElement, + originalSubmitElement, + ); + addSshKeyValidation.toggleWarning(true); + + expect(warningElement.classList.contains('hide')).toBeFalsy(); + expect(originalSubmitElement.classList.contains('hide')).toBeTruthy(); + }); + + it('hides warningElement and shows originalSubmitElement if isVisible is false', () => { + const warningElement = document.createElement('div'); + const originalSubmitElement = document.createElement('div'); + originalSubmitElement.classList.add('hide'); + + const addSshKeyValidation = new AddSshKeyValidation( + {}, + warningElement, + originalSubmitElement, + ); + addSshKeyValidation.toggleWarning(false); + + expect(warningElement.classList.contains('hide')).toBeTruthy(); + expect(originalSubmitElement.classList.contains('hide')).toBeFalsy(); + }); + }); + + describe('isPublicKey', () => { + it('returns false if probably invalid public ssh key', () => { + expect(AddSshKeyValidation.isPublicKey('nope')).toBeFalsy(); + }); + + it('returns true if probably valid public ssh key', () => { + expect(AddSshKeyValidation.isPublicKey('ssh-')).toBeTruthy(); + expect(AddSshKeyValidation.isPublicKey('ecdsa-sha2-')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/project_select_combo_button_spec.js b/spec/frontend/project_select_combo_button_spec.js new file mode 100644 index 00000000000..c47db71b4ac --- /dev/null +++ b/spec/frontend/project_select_combo_button_spec.js @@ -0,0 +1,140 @@ +import $ from 'jquery'; +import ProjectSelectComboButton from '~/project_select_combo_button'; + +const fixturePath = 'static/project_select_combo_button.html'; + +describe('Project Select Combo Button', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + preloadFixtures(fixturePath); + + beforeEach(() => { + testContext.defaults = { + label: 'Select project to create issue', + groupId: 12345, + projectMeta: { + name: 'My Cool Project', + url: 'http://mycoolproject.com', + }, + newProjectMeta: { + name: 'My Other Cool Project', + url: 'http://myothercoolproject.com', + }, + localStorageKey: 'group-12345-new-issue-recent-project', + relativePath: 'issues/new', + }; + + loadFixtures(fixturePath); + + testContext.newItemBtn = document.querySelector('.new-project-item-link'); + testContext.projectSelectInput = document.querySelector('.project-item-select'); + }); + + describe('on page load when localStorage is empty', () => { + beforeEach(() => { + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + }); + + it('newItemBtn href is null', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe(''); + }); + + it('newItemBtn text is the plain default label', () => { + expect(testContext.newItemBtn.textContent).toBe(testContext.defaults.label); + }); + }); + + describe('on page load when localStorage is filled', () => { + beforeEach(() => { + window.localStorage.setItem( + testContext.defaults.localStorageKey, + JSON.stringify(testContext.defaults.projectMeta), + ); + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + }); + + it('newItemBtn href is correctly set', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe( + testContext.defaults.projectMeta.url, + ); + }); + + it('newItemBtn text is the cached label', () => { + expect(testContext.newItemBtn.textContent).toBe( + `New issue in ${testContext.defaults.projectMeta.name}`, + ); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + }); + + describe('after selecting a new project', () => { + beforeEach(() => { + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + + // mock the effect of selecting an item from the projects dropdown (select2) + $('.project-item-select') + .val(JSON.stringify(testContext.defaults.newProjectMeta)) + .trigger('change'); + }); + + it('newItemBtn href is correctly set', () => { + expect(testContext.newItemBtn.getAttribute('href')).toBe( + 'http://myothercoolproject.com/issues/new', + ); + }); + + it('newItemBtn text is the selected project label', () => { + expect(testContext.newItemBtn.textContent).toBe( + `New issue in ${testContext.defaults.newProjectMeta.name}`, + ); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + }); + + describe('deriveTextVariants', () => { + beforeEach(() => { + testContext.mockExecutionContext = { + resourceType: '', + resourceLabel: '', + }; + + testContext.comboButton = new ProjectSelectComboButton(testContext.projectSelectInput); + + testContext.method = testContext.comboButton.deriveTextVariants.bind( + testContext.mockExecutionContext, + ); + }); + + it('correctly derives test variants for merge requests', () => { + testContext.mockExecutionContext.resourceType = 'merge_requests'; + testContext.mockExecutionContext.resourceLabel = 'New merge request'; + + const returnedVariants = testContext.method(); + + expect(returnedVariants.localStorageItemType).toBe('new-merge-request'); + expect(returnedVariants.defaultTextPrefix).toBe('New merge request'); + expect(returnedVariants.presetTextSuffix).toBe('merge request'); + }); + + it('correctly derives text variants for issues', () => { + testContext.mockExecutionContext.resourceType = 'issues'; + testContext.mockExecutionContext.resourceLabel = 'New issue'; + + const returnedVariants = testContext.method(); + + expect(returnedVariants.localStorageItemType).toBe('new-issue'); + expect(returnedVariants.defaultTextPrefix).toBe('New issue'); + expect(returnedVariants.presetTextSuffix).toBe('issue'); + }); + }); +}); diff --git a/spec/frontend/shared/popover_spec.js b/spec/frontend/shared/popover_spec.js new file mode 100644 index 00000000000..bbde936185e --- /dev/null +++ b/spec/frontend/shared/popover_spec.js @@ -0,0 +1,166 @@ +import $ from 'jquery'; +import { togglePopover, mouseleave, mouseenter } from '~/shared/popover'; + +describe('popover', () => { + describe('togglePopover', () => { + describe('togglePopover(true)', () => { + it('returns true when popover is shown', () => { + const context = { + hasClass: () => false, + popover: () => {}, + toggleClass: () => {}, + }; + + expect(togglePopover.call(context, true)).toEqual(true); + }); + + it('returns false when popover is already shown', () => { + const context = { + hasClass: () => true, + }; + + expect(togglePopover.call(context, true)).toEqual(false); + }); + + it('shows popover', done => { + const context = { + hasClass: () => false, + popover: () => {}, + toggleClass: () => {}, + }; + + jest.spyOn(context, 'popover').mockImplementation(method => { + expect(method).toEqual('show'); + done(); + }); + + togglePopover.call(context, true); + }); + + it('adds disable-animation and js-popover-show class', done => { + const context = { + hasClass: () => false, + popover: () => {}, + toggleClass: () => {}, + }; + + jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { + expect(classNames).toEqual('disable-animation js-popover-show'); + expect(show).toEqual(true); + done(); + }); + + togglePopover.call(context, true); + }); + }); + + describe('togglePopover(false)', () => { + it('returns true when popover is hidden', () => { + const context = { + hasClass: () => true, + popover: () => {}, + toggleClass: () => {}, + }; + + expect(togglePopover.call(context, false)).toEqual(true); + }); + + it('returns false when popover is already hidden', () => { + const context = { + hasClass: () => false, + }; + + expect(togglePopover.call(context, false)).toEqual(false); + }); + + it('hides popover', done => { + const context = { + hasClass: () => true, + popover: () => {}, + toggleClass: () => {}, + }; + + jest.spyOn(context, 'popover').mockImplementation(method => { + expect(method).toEqual('hide'); + done(); + }); + + togglePopover.call(context, false); + }); + + it('removes disable-animation and js-popover-show class', done => { + const context = { + hasClass: () => true, + popover: () => {}, + toggleClass: () => {}, + }; + + jest.spyOn(context, 'toggleClass').mockImplementation((classNames, show) => { + expect(classNames).toEqual('disable-animation js-popover-show'); + expect(show).toEqual(false); + done(); + }); + + togglePopover.call(context, false); + }); + }); + }); + + describe('mouseleave', () => { + it('calls hide popover if .popover:hover is false', () => { + const fakeJquery = { + length: 0, + }; + + jest + .spyOn($.fn, 'init') + .mockImplementation(selector => (selector === '.popover:hover' ? fakeJquery : $.fn)); + jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); + mouseleave(); + + expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), false); + }); + + it('does not call hide popover if .popover:hover is true', () => { + const fakeJquery = { + length: 1, + }; + + jest + .spyOn($.fn, 'init') + .mockImplementation(selector => (selector === '.popover:hover' ? fakeJquery : $.fn)); + jest.spyOn(togglePopover, 'call').mockImplementation(() => {}); + mouseleave(); + + expect(togglePopover.call).not.toHaveBeenCalledWith(false); + }); + }); + + describe('mouseenter', () => { + const context = {}; + + it('shows popover', () => { + jest.spyOn(togglePopover, 'call').mockReturnValue(false); + mouseenter.call(context); + + expect(togglePopover.call).toHaveBeenCalledWith(expect.any(Object), true); + }); + + it('registers mouseleave event if popover is showed', done => { + jest.spyOn(togglePopover, 'call').mockReturnValue(true); + jest.spyOn($.fn, 'on').mockImplementation(eventName => { + expect(eventName).toEqual('mouseleave'); + done(); + }); + mouseenter.call(context); + }); + + it('does not register mouseleave event if popover is not showed', () => { + jest.spyOn(togglePopover, 'call').mockReturnValue(false); + const spy = jest.spyOn($.fn, 'on').mockImplementation(() => {}); + mouseenter.call(context); + + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/sidebar_store_spec.js new file mode 100644 index 00000000000..6d063a7cfcf --- /dev/null +++ b/spec/frontend/sidebar/sidebar_store_spec.js @@ -0,0 +1,168 @@ +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +const ASSIGNEE = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const ANOTHER_ASSINEE = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', +}; + +const PARTICIPANT = { + id: 1, + state: 'active', + username: 'marcene', + name: 'Allie Will', + web_url: 'foo.com', + avatar_url: 'gravatar.com/avatar/xxx', +}; + +const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPANT, id: 3 }]; + +describe('Sidebar store', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + beforeEach(() => { + testContext.store = new SidebarStore({ + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + editable: true, + rootPath: '/', + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + }); + }); + + afterEach(() => { + SidebarStore.singleton = null; + }); + + it('has default isFetching values', () => { + expect(testContext.store.isFetching.assignees).toBe(true); + }); + + it('adds a new assignee', () => { + testContext.store.addAssignee(ASSIGNEE); + + expect(testContext.store.assignees.length).toEqual(1); + }); + + it('removes an assignee', () => { + testContext.store.removeAssignee(ASSIGNEE); + + expect(testContext.store.assignees.length).toEqual(0); + }); + + it('finds an existent assignee', () => { + let foundAssignee; + + testContext.store.addAssignee(ASSIGNEE); + foundAssignee = testContext.store.findAssignee(ASSIGNEE); + + expect(foundAssignee).toBeDefined(); + expect(foundAssignee).toEqual(ASSIGNEE); + foundAssignee = testContext.store.findAssignee(ANOTHER_ASSINEE); + + expect(foundAssignee).toBeUndefined(); + }); + + it('removes all assignees', () => { + testContext.store.removeAllAssignees(); + + expect(testContext.store.assignees.length).toEqual(0); + }); + + it('sets participants data', () => { + expect(testContext.store.participants.length).toEqual(0); + + testContext.store.setParticipantsData({ + participants: PARTICIPANT_LIST, + }); + + expect(testContext.store.isFetching.participants).toEqual(false); + expect(testContext.store.participants.length).toEqual(PARTICIPANT_LIST.length); + }); + + it('sets subcriptions data', () => { + expect(testContext.store.subscribed).toEqual(null); + + testContext.store.setSubscriptionsData({ + subscribed: true, + }); + + expect(testContext.store.isFetching.subscriptions).toEqual(false); + expect(testContext.store.subscribed).toEqual(true); + }); + + it('set assigned data', () => { + const users = { + assignees: UsersMockHelper.createNumberRandomUsers(3), + }; + + testContext.store.setAssigneeData(users); + + expect(testContext.store.isFetching.assignees).toBe(false); + expect(testContext.store.assignees.length).toEqual(3); + }); + + it('sets fetching state', () => { + expect(testContext.store.isFetching.participants).toEqual(true); + + testContext.store.setFetchingState('participants', false); + + expect(testContext.store.isFetching.participants).toEqual(false); + }); + + it('sets loading state', () => { + testContext.store.setLoadingState('assignees', true); + + expect(testContext.store.isLoading.assignees).toEqual(true); + }); + + it('set time tracking data', () => { + testContext.store.setTimeTrackingData(Mock.time); + + expect(testContext.store.timeEstimate).toEqual(Mock.time.time_estimate); + expect(testContext.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); + expect(testContext.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); + expect(testContext.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); + }); + + it('set autocomplete projects', () => { + const projects = [{ id: 0 }]; + testContext.store.setAutocompleteProjects(projects); + + expect(testContext.store.autocompleteProjects).toEqual(projects); + }); + + it('sets subscribed state', () => { + expect(testContext.store.subscribed).toEqual(null); + + testContext.store.setSubscribedState(true); + + expect(testContext.store.subscribed).toEqual(true); + }); + + it('set move to project ID', () => { + const projectId = 7; + testContext.store.setMoveToProjectId(projectId); + + expect(testContext.store.moveToProjectId).toEqual(projectId); + }); +}); diff --git a/spec/frontend/syntax_highlight_spec.js b/spec/frontend/syntax_highlight_spec.js new file mode 100644 index 00000000000..d2fb5983f7b --- /dev/null +++ b/spec/frontend/syntax_highlight_spec.js @@ -0,0 +1,48 @@ +/* eslint-disable no-return-assign */ + +import $ from 'jquery'; +import syntaxHighlight from '~/syntax_highlight'; + +describe('Syntax Highlighter', () => { + const stubUserColorScheme = value => { + if (window.gon == null) { + window.gon = {}; + } + return (window.gon.user_color_scheme = value); + }; + describe('on a js-syntax-highlight element', () => { + beforeEach(() => { + setFixtures('<div class="js-syntax-highlight"></div>'); + }); + + it('applies syntax highlighting', () => { + stubUserColorScheme('monokai'); + syntaxHighlight($('.js-syntax-highlight')); + + expect($('.js-syntax-highlight')).toHaveClass('monokai'); + }); + }); + + describe('on a parent element', () => { + beforeEach(() => { + setFixtures( + '<div class="parent">\n <div class="js-syntax-highlight"></div>\n <div class="foo"></div>\n <div class="js-syntax-highlight"></div>\n</div>', + ); + }); + + it('applies highlighting to all applicable children', () => { + stubUserColorScheme('monokai'); + syntaxHighlight($('.parent')); + + expect($('.parent, .foo')).not.toHaveClass('monokai'); + expect($('.monokai').length).toBe(2); + }); + + it('prevents an infinite loop when no matches exist', () => { + setFixtures('<div></div>'); + const highlight = () => syntaxHighlight($('div')); + + expect(highlight).not.toThrow(); + }); + }); +}); diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js new file mode 100644 index 00000000000..1261833e3ec --- /dev/null +++ b/spec/frontend/task_list_spec.js @@ -0,0 +1,156 @@ +import $ from 'jquery'; +import TaskList from '~/task_list'; +import axios from '~/lib/utils/axios_utils'; + +describe('TaskList', () => { + let taskList; + let currentTarget; + const taskListOptions = { + selector: '.task-list', + dataType: 'issue', + fieldName: 'description', + lockVersion: 2, + }; + const createTaskList = () => new TaskList(taskListOptions); + + beforeEach(() => { + setFixtures(` + <div class="task-list"> + <div class="js-task-list-container"></div> + </div> + `); + + currentTarget = $('<div></div>'); + taskList = createTaskList(); + }); + + it('should call init when the class constructed', () => { + jest.spyOn(TaskList.prototype, 'init'); + jest.spyOn(TaskList.prototype, 'disable').mockImplementation(() => {}); + jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); + jest.spyOn($.prototype, 'on').mockImplementation(() => {}); + + taskList = createTaskList(); + const $taskListEl = $(taskList.taskListContainerSelector); + + expect(taskList.init).toHaveBeenCalled(); + expect(taskList.disable).toHaveBeenCalled(); + expect($taskListEl.taskList).toHaveBeenCalledWith('enable'); + expect($(document).on).toHaveBeenCalledWith( + 'tasklist:changed', + taskList.taskListContainerSelector, + taskList.updateHandler, + ); + }); + + describe('getTaskListTarget', () => { + it('should return currentTarget from event object if exists', () => { + const $target = taskList.getTaskListTarget({ currentTarget }); + + expect($target).toEqual(currentTarget); + }); + + it('should return element of the taskListContainerSelector', () => { + const $target = taskList.getTaskListTarget(); + + expect($target).toEqual($(taskList.taskListContainerSelector)); + }); + }); + + describe('disableTaskListItems', () => { + it('should call taskList method with disable param', () => { + jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); + + taskList.disableTaskListItems({ currentTarget }); + + expect(currentTarget.taskList).toHaveBeenCalledWith('disable'); + }); + }); + + describe('enableTaskListItems', () => { + it('should call taskList method with enable param', () => { + jest.spyOn($.prototype, 'taskList').mockImplementation(() => {}); + + taskList.enableTaskListItems({ currentTarget }); + + expect(currentTarget.taskList).toHaveBeenCalledWith('enable'); + }); + }); + + describe('disable', () => { + it('should disable task list items and off document event', () => { + jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); + jest.spyOn($.prototype, 'off').mockImplementation(() => {}); + + taskList.disable(); + + expect(taskList.disableTaskListItems).toHaveBeenCalled(); + expect($(document).off).toHaveBeenCalledWith( + 'tasklist:changed', + taskList.taskListContainerSelector, + ); + }); + }); + + describe('update', () => { + it('should disable task list items and make a patch request then enable them again', done => { + const response = { data: { lock_version: 3 } }; + jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'onSuccess').mockImplementation(() => {}); + jest.spyOn(axios, 'patch').mockReturnValue(Promise.resolve(response)); + + const value = 'hello world'; + const endpoint = '/foo'; + const target = $(`<input data-update-url="${endpoint}" value="${value}" />`); + const detail = { + index: 2, + checked: true, + lineNumber: 8, + lineSource: '- [ ] check item', + }; + const event = { target, detail }; + const patchData = { + [taskListOptions.dataType]: { + [taskListOptions.fieldName]: value, + lock_version: taskListOptions.lockVersion, + update_task: { + index: detail.index, + checked: detail.checked, + line_number: detail.lineNumber, + line_source: detail.lineSource, + }, + }, + }; + + taskList + .update(event) + .then(() => { + expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); + expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); + expect(taskList.lockVersion).toEqual(response.data.lock_version); + }) + .then(done) + .catch(done.fail); + }); + }); + + it('should handle request error and enable task list items', done => { + const response = { data: { error: 1 } }; + jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); + jest.spyOn(taskList, 'onError').mockImplementation(() => {}); + jest.spyOn(axios, 'patch').mockReturnValue(Promise.reject({ response })); // eslint-disable-line prefer-promise-reject-errors + + const event = { detail: {} }; + taskList + .update(event) + .then(() => { + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onError).toHaveBeenCalledWith(response.data); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/version_check_image_spec.js b/spec/frontend/version_check_image_spec.js new file mode 100644 index 00000000000..2ab157105a1 --- /dev/null +++ b/spec/frontend/version_check_image_spec.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; +import VersionCheckImage from '~/version_check_image'; +import ClassSpecHelper from './helpers/class_spec_helper'; + +describe('VersionCheckImage', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + }); + + describe('bindErrorEvent', () => { + ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); + + beforeEach(() => { + testContext.imageElement = $('<div></div>'); + }); + + it('registers an error event', () => { + jest.spyOn($.prototype, 'on').mockImplementation(() => {}); + // eslint-disable-next-line func-names + jest.spyOn($.prototype, 'off').mockImplementation(function() { + return this; + }); + + VersionCheckImage.bindErrorEvent(testContext.imageElement); + + expect($.prototype.off).toHaveBeenCalledWith('error'); + expect($.prototype.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it('hides the imageElement on error', () => { + jest.spyOn($.prototype, 'hide').mockImplementation(() => {}); + + VersionCheckImage.bindErrorEvent(testContext.imageElement); + + testContext.imageElement.trigger('error'); + + expect($.prototype.hide).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js new file mode 100644 index 00000000000..4b7636041b6 --- /dev/null +++ b/spec/frontend/vue_shared/components/gl_modal_vuex_spec.js @@ -0,0 +1,151 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlModal } from '@gitlab/ui'; +import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; +import createState from '~/vuex_shared/modules/modal/state'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +const TEST_SLOT = 'Lorem ipsum modal dolar sit.'; +const TEST_MODAL_ID = 'my-modal-id'; +const TEST_MODULE = 'myModal'; + +describe('GlModalVuex', () => { + let wrapper; + let state; + let actions; + + const factory = (options = {}) => { + const store = new Vuex.Store({ + modules: { + [TEST_MODULE]: { + namespaced: true, + state, + actions, + }, + }, + }); + + const propsData = { + modalId: TEST_MODAL_ID, + modalModule: TEST_MODULE, + ...options.propsData, + }; + + wrapper = shallowMount(localVue.extend(GlModalVuex), { + ...options, + localVue, + store, + propsData, + }); + }; + + beforeEach(() => { + state = createState(); + + actions = { + show: jest.fn(), + hide: jest.fn(), + }; + }); + + it('renders gl-modal', () => { + factory({ + slots: { + default: `<div>${TEST_SLOT}</div>`, + }, + }); + const glModal = wrapper.find(GlModal); + + expect(glModal.props('modalId')).toBe(TEST_MODAL_ID); + expect(glModal.text()).toContain(TEST_SLOT); + }); + + it('passes props through to gl-modal', () => { + const title = 'Test Title'; + const okVariant = 'success'; + + factory({ + propsData: { + title, + okTitle: title, + okVariant, + }, + }); + const glModal = wrapper.find(GlModal); + + expect(glModal.attributes('title')).toEqual(title); + expect(glModal.attributes('oktitle')).toEqual(title); + expect(glModal.attributes('okvariant')).toEqual(okVariant); + }); + + it('passes listeners through to gl-modal', () => { + const ok = jest.fn(); + + factory({ + listeners: { ok }, + }); + + const glModal = wrapper.find(GlModal); + glModal.vm.$emit('ok'); + + expect(ok).toHaveBeenCalledTimes(1); + }); + + it('calls vuex action on show', () => { + expect(actions.show).not.toHaveBeenCalled(); + + factory(); + + const glModal = wrapper.find(GlModal); + glModal.vm.$emit('shown'); + + expect(actions.show).toHaveBeenCalledTimes(1); + }); + + it('calls vuex action on hide', () => { + expect(actions.hide).not.toHaveBeenCalled(); + + factory(); + + const glModal = wrapper.find(GlModal); + glModal.vm.$emit('hidden'); + + expect(actions.hide).toHaveBeenCalledTimes(1); + }); + + it('calls bootstrap show when isVisible changes', done => { + state.isVisible = false; + + factory(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + state.isVisible = true; + + localVue + .nextTick() + .then(() => { + expect(rootEmit).toHaveBeenCalledWith('bv::show::modal', TEST_MODAL_ID); + }) + .then(done) + .catch(done.fail); + }); + + it('calls bootstrap hide when isVisible changes', done => { + state.isVisible = true; + + factory(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + state.isVisible = false; + + localVue + .nextTick() + .then(() => { + expect(rootEmit).toHaveBeenCalledWith('bv::hide::modal', TEST_MODAL_ID); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js new file mode 100644 index 00000000000..3c5e7500ba7 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion_diff.vue'; +import { selectDiffLines } from '~/vue_shared/components/lib/utils/diff_utils'; + +const MOCK_DATA = { + canApply: true, + suggestion: { + id: 1, + diff_lines: [ + { + can_receive_suggestion: false, + line_code: null, + meta_data: null, + new_line: null, + old_line: 5, + rich_text: '-test', + text: '-test', + type: 'old', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test', + text: '+new test', + type: 'new', + }, + { + can_receive_suggestion: true, + line_code: null, + meta_data: null, + new_line: 5, + old_line: null, + rich_text: '+new test2', + text: '+new test2', + type: 'new', + }, + ], + }, + helpPagePath: 'path_to_docs', +}; + +const lines = selectDiffLines(MOCK_DATA.suggestion.diff_lines); +const newLines = lines.filter(line => line.type === 'new'); + +describe('Suggestion Diff component', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(SuggestionDiffComponent); + + vm = new Component({ + propsData: MOCK_DATA, + }).$mount(); + + Vue.nextTick(done); + }); + + describe('init', () => { + it('renders a suggestion header', () => { + expect(vm.$el.querySelector('.js-suggestion-diff-header')).not.toBeNull(); + }); + + it('renders a diff table with syntax highlighting', () => { + expect(vm.$el.querySelector('.md-suggestion-diff.js-syntax-highlight.code')).not.toBeNull(); + }); + + it('renders the oldLineNumber', () => { + const fromLine = vm.$el.querySelector('.old_line').innerHTML; + + expect(parseInt(fromLine, 10)).toBe(lines[0].old_line); + }); + + it('renders the oldLineContent', () => { + const fromContent = vm.$el.querySelector('.line_content.old').innerHTML; + + expect(fromContent.includes(lines[0].text)).toBe(true); + }); + + it('renders new lines', () => { + const newLinesElements = vm.$el.querySelectorAll('.line_holder.new'); + + newLinesElements.forEach((line, i) => { + expect(newLinesElements[i].innerHTML.includes(newLines[i].new_line)).toBe(true); + expect(newLinesElements[i].innerHTML.includes(newLines[i].text)).toBe(true); + }); + }); + }); + + describe('applySuggestion', () => { + it('emits apply event when applySuggestion is called', () => { + const callback = () => {}; + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.applySuggestion(callback); + + expect(vm.$emit).toHaveBeenCalledWith('apply', { suggestionId: vm.suggestion.id, callback }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js new file mode 100644 index 00000000000..9f0cdc651b6 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -0,0 +1,156 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; + +const TEST_IMAGE_SIZE = 7; +const TEST_BREAKPOINT = 5; +const TEST_EMPTY_MESSAGE = 'Lorem ipsum empty'; +const DEFAULT_EMPTY_MESSAGE = 'None'; + +const createUser = id => ({ + id, + name: 'Lorem', + web_url: `${TEST_HOST}/${id}`, + avatar_url: `${TEST_HOST}/${id}/avatar`, +}); +const createList = n => + Array(n) + .fill(1) + .map((x, id) => createUser(id)); + +const localVue = createLocalVue(); + +describe('UserAvatarList', () => { + let props; + let wrapper; + + const factory = (options = {}) => { + const propsData = { + ...props, + ...options.propsData, + }; + + wrapper = shallowMount(localVue.extend(UserAvatarList), { + ...options, + localVue, + propsData, + }); + }; + + const clickButton = () => { + const button = wrapper.find(GlButton); + button.vm.$emit('click'); + }; + + beforeEach(() => { + props = { imgSize: TEST_IMAGE_SIZE }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('empty text', () => { + it('shows when items are empty', () => { + factory({ propsData: { items: [] } }); + + expect(wrapper.text()).toContain(DEFAULT_EMPTY_MESSAGE); + }); + + it('does not show when items are not empty', () => { + factory({ propsData: { items: createList(1) } }); + + expect(wrapper.text()).not.toContain(DEFAULT_EMPTY_MESSAGE); + }); + + it('can be set in props', () => { + factory({ propsData: { items: [], emptyText: TEST_EMPTY_MESSAGE } }); + + expect(wrapper.text()).toContain(TEST_EMPTY_MESSAGE); + }); + }); + + describe('with no breakpoint', () => { + beforeEach(() => { + props.breakpoint = 0; + }); + + it('renders avatars', () => { + const items = createList(20); + factory({ propsData: { items } }); + + const links = wrapper.findAll(UserAvatarLink); + const linkProps = links.wrappers.map(x => x.props()); + + expect(linkProps).toEqual( + items.map(x => + expect.objectContaining({ + linkHref: x.web_url, + imgSrc: x.avatar_url, + imgAlt: x.name, + tooltipText: x.name, + imgSize: TEST_IMAGE_SIZE, + }), + ), + ); + }); + }); + + describe('with breakpoint and length equal to breakpoint', () => { + beforeEach(() => { + props.breakpoint = TEST_BREAKPOINT; + props.items = createList(TEST_BREAKPOINT); + }); + + it('renders all avatars if length is <= breakpoint', () => { + factory(); + + const links = wrapper.findAll(UserAvatarLink); + + expect(links.length).toEqual(props.items.length); + }); + + it('does not show button', () => { + factory(); + + expect(wrapper.find(GlButton).exists()).toBe(false); + }); + }); + + describe('with breakpoint and length greater than breakpoint', () => { + beforeEach(() => { + props.breakpoint = TEST_BREAKPOINT; + props.items = createList(TEST_BREAKPOINT + 1); + }); + + it('renders avatars up to breakpoint', () => { + factory(); + + const links = wrapper.findAll(UserAvatarLink); + + expect(links.length).toEqual(TEST_BREAKPOINT); + }); + + describe('with expand clicked', () => { + beforeEach(() => { + factory(); + clickButton(); + }); + + it('renders all avatars', () => { + const links = wrapper.findAll(UserAvatarLink); + + expect(links.length).toEqual(props.items.length); + }); + + it('with collapse clicked, it renders avatars up to breakpoint', () => { + clickButton(); + const links = wrapper.findAll(UserAvatarLink); + + expect(links.length).toEqual(TEST_BREAKPOINT); + }); + }); + }); +}); |