Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-12-20 18:07:34 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2019-12-20 18:07:34 +0300
commit8b61452138ecc511b52cd49be4ee6b8a80390c50 (patch)
tree122b817432c2a0f0e23767bd95791a89b20540c0 /spec/frontend
parentf864f8a7aafa45b0e4c04e4312f89da4b1227c0f (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/behaviors/bind_in_out_spec.js204
-rw-r--r--spec/frontend/bootstrap_jquery_spec.js44
-rw-r--r--spec/frontend/branches/branches_delete_modal_spec.js40
-rw-r--r--spec/frontend/breakpoints_spec.js27
-rw-r--r--spec/frontend/diffs/components/settings_dropdown_spec.js167
-rw-r--r--spec/frontend/droplab/constants_spec.js39
-rw-r--r--spec/frontend/droplab/plugins/ajax_filter_spec.js72
-rw-r--r--spec/frontend/droplab/plugins/ajax_spec.js41
-rw-r--r--spec/frontend/feature_highlight/feature_highlight_options_spec.js30
-rw-r--r--spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js201
-rw-r--r--spec/frontend/filtered_search/dropdown_user_spec.js113
-rw-r--r--spec/frontend/frequent_items/components/frequent_items_search_input_spec.js83
-rw-r--r--spec/frontend/gl_field_errors_spec.js144
-rw-r--r--spec/frontend/gpg_badges_spec.js92
-rw-r--r--spec/frontend/header_spec.js53
-rw-r--r--spec/frontend/helpers/class_spec_helper_spec.js26
-rw-r--r--spec/frontend/ide/components/commit_sidebar/stage_button_spec.js46
-rw-r--r--spec/frontend/ide/components/commit_sidebar/unstage_button_spec.js39
-rw-r--r--spec/frontend/ide/components/jobs/detail/scroll_button_spec.js59
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js805
-rw-r--r--spec/frontend/image_diff/helpers/init_image_diff_spec.js52
-rw-r--r--spec/frontend/image_diff/init_discussion_tab_spec.js42
-rw-r--r--spec/frontend/issue_show/components/edit_actions_spec.js134
-rw-r--r--spec/frontend/issue_show/components/fields/description_spec.js70
-rw-r--r--spec/frontend/issue_show/components/fields/title_spec.js48
-rw-r--r--spec/frontend/issue_show/index_spec.js19
-rw-r--r--spec/frontend/jobs/components/job_log_controllers_spec.js208
-rw-r--r--spec/frontend/namespace_select_spec.js66
-rw-r--r--spec/frontend/new_branch_spec.js203
-rw-r--r--spec/frontend/notes/components/discussion_filter_note_spec.js93
-rw-r--r--spec/frontend/notes/components/note_header_spec.js125
-rw-r--r--spec/frontend/notes/stores/getters_spec.js388
-rw-r--r--spec/frontend/notes/stores/mutation_spec.js584
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js244
-rw-r--r--spec/frontend/pipelines/nav_controls_spec.js85
-rw-r--r--spec/frontend/polyfills/element_spec.js46
-rw-r--r--spec/frontend/profile/add_ssh_key_validation_spec.js71
-rw-r--r--spec/frontend/project_select_combo_button_spec.js140
-rw-r--r--spec/frontend/shared/popover_spec.js166
-rw-r--r--spec/frontend/sidebar/sidebar_store_spec.js168
-rw-r--r--spec/frontend/syntax_highlight_spec.js48
-rw-r--r--spec/frontend/task_list_spec.js156
-rw-r--r--spec/frontend/version_check_image_spec.js42
-rw-r--r--spec/frontend/vue_shared/components/gl_modal_vuex_spec.js151
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js101
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js156
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: '&lt;img src=x onerror=alert(1)&gt;',
+ });
+ 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);
+ });
+ });
+ });
+});