diff options
Diffstat (limited to 'spec/javascripts')
39 files changed, 2698 insertions, 413 deletions
diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js new file mode 100644 index 00000000000..9f9acc392c2 --- /dev/null +++ b/spec/javascripts/autosave_spec.js @@ -0,0 +1,134 @@ +import Autosave from '~/autosave'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Autosave', () => { + let autosave; + + describe('class constructor', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['data', 'on']); + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); + spyOn(Autosave.prototype, 'restore'); + + autosave = new Autosave(field, key); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(autosave.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('restore', () => { + const key = 'key'; + const field = jasmine.createSpyObj('field', ['trigger']); + + beforeEach(() => { + autosave = { + field, + key, + }; + + spyOn(window.localStorage, 'getItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.restore.call(autosave); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.restore.call(autosave); + }); + + it('should call .getItem', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); + }); + }); + }); + + describe('save', () => { + const field = jasmine.createSpyObj('field', ['val']); + + beforeEach(() => { + autosave = jasmine.createSpyObj('autosave', ['reset']); + autosave.field = field; + + field.val.and.returnValue('value'); + + spyOn(window.localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.save.call(autosave); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.save.call(autosave); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalled(); + }); + }); + }); + + describe('reset', () => { + const key = 'key'; + + beforeEach(() => { + autosave = { + key, + }; + + spyOn(window.localStorage, 'removeItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = false; + + Autosave.prototype.reset.call(autosave); + }); + + it('should not call .removeItem', () => { + expect(window.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(() => { + autosave.isLocalStorageAvailable = true; + + Autosave.prototype.reset.call(autosave); + }); + + it('should call .removeItem', () => { + expect(window.localStorage.removeItem).toHaveBeenCalledWith(key); + }); + }); + }); +}); diff --git a/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js new file mode 100644 index 00000000000..1ed96a67478 --- /dev/null +++ b/spec/javascripts/behaviors/gl_emoji/unicode_support_map_spec.js @@ -0,0 +1,47 @@ +import { getUnicodeSupportMap } from '~/behaviors/gl_emoji/unicode_support_map'; +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('Unicode Support Map', () => { + describe('getUnicodeSupportMap', () => { + const stringSupportMap = 'stringSupportMap'; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe'); + spyOn(window.localStorage, 'getItem'); + spyOn(window.localStorage, 'setItem'); + spyOn(JSON, 'parse'); + spyOn(JSON, 'stringify').and.returnValue(stringSupportMap); + }); + + describe('if isLocalStorageAvailable is `true`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(true); + + getUnicodeSupportMap(); + }); + + it('should call .getItem and .setItem', () => { + const allArgs = window.localStorage.setItem.calls.allArgs(); + + expect(window.localStorage.getItem).toHaveBeenCalledWith('gl-emoji-user-agent'); + expect(allArgs[0][0]).toBe('gl-emoji-user-agent'); + expect(allArgs[0][1]).toBe(navigator.userAgent); + expect(allArgs[1][0]).toBe('gl-emoji-unicode-support-map'); + expect(allArgs[1][1]).toBe(stringSupportMap); + }); + }); + + describe('if isLocalStorageAvailable is `false`', function () { + beforeEach(() => { + AccessorUtilities.isLocalStorageAccessSafe.and.returnValue(false); + + getUnicodeSupportMap(); + }); + + it('should not call .getItem or .setItem', () => { + expect(window.localStorage.getItem.calls.count()).toBe(1); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js new file mode 100644 index 00000000000..85816ee1f11 --- /dev/null +++ b/spec/javascripts/blob/balsamiq/balsamiq_viewer_spec.js @@ -0,0 +1,342 @@ +import sqljs from 'sql.js'; +import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; +import ClassSpecHelper from '../../helpers/class_spec_helper'; + +describe('BalsamiqViewer', () => { + let balsamiqViewer; + let endpoint; + let viewer; + + describe('class constructor', () => { + beforeEach(() => { + endpoint = 'endpoint'; + viewer = { + dataset: { + endpoint, + }, + }; + + balsamiqViewer = new BalsamiqViewer(viewer); + }); + + it('should set .viewer', () => { + expect(balsamiqViewer.viewer).toBe(viewer); + }); + + it('should set .endpoint', () => { + expect(balsamiqViewer.endpoint).toBe(endpoint); + }); + }); + + describe('loadFile', () => { + let xhr; + + beforeEach(() => { + endpoint = 'endpoint'; + xhr = jasmine.createSpyObj('xhr', ['open', 'send']); + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderFile']); + balsamiqViewer.endpoint = endpoint; + + spyOn(window, 'XMLHttpRequest').and.returnValue(xhr); + + BalsamiqViewer.prototype.loadFile.call(balsamiqViewer); + }); + + it('should call .open', () => { + expect(xhr.open).toHaveBeenCalledWith('GET', endpoint, true); + }); + + it('should set .responseType', () => { + expect(xhr.responseType).toBe('arraybuffer'); + }); + + it('should call .send', () => { + expect(xhr.send).toHaveBeenCalled(); + }); + }); + + describe('renderFile', () => { + let container; + let loadEvent; + let previews; + + beforeEach(() => { + loadEvent = { target: { response: {} } }; + viewer = jasmine.createSpyObj('viewer', ['appendChild']); + previews = [document.createElement('ul'), document.createElement('ul')]; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['initDatabase', 'getPreviews', 'renderPreview']); + balsamiqViewer.viewer = viewer; + + balsamiqViewer.getPreviews.and.returnValue(previews); + balsamiqViewer.renderPreview.and.callFake(preview => preview); + viewer.appendChild.and.callFake((containerElement) => { + container = containerElement; + }); + + BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, loadEvent); + }); + + it('should call .initDatabase', () => { + expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(loadEvent.target.response); + }); + + it('should call .getPreviews', () => { + expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); + }); + + it('should call .renderPreview for each preview', () => { + const allArgs = balsamiqViewer.renderPreview.calls.allArgs(); + + expect(allArgs.length).toBe(2); + + previews.forEach((preview, i) => { + expect(allArgs[i][0]).toBe(preview); + }); + }); + + it('should set the container HTML', () => { + expect(container.innerHTML).toBe('<ul></ul><ul></ul>'); + }); + + it('should add inline preview classes', () => { + expect(container.classList[0]).toBe('list-inline'); + expect(container.classList[1]).toBe('previews'); + }); + + it('should call viewer.appendChild', () => { + expect(viewer.appendChild).toHaveBeenCalledWith(container); + }); + }); + + describe('initDatabase', () => { + let database; + let uint8Array; + let data; + + beforeEach(() => { + uint8Array = {}; + database = {}; + data = 'data'; + + balsamiqViewer = {}; + + spyOn(window, 'Uint8Array').and.returnValue(uint8Array); + spyOn(sqljs, 'Database').and.returnValue(database); + + BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); + }); + + it('should instantiate Uint8Array', () => { + expect(window.Uint8Array).toHaveBeenCalledWith(data); + }); + + it('should call sqljs.Database', () => { + expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); + }); + + it('should set .database', () => { + expect(balsamiqViewer.database).toBe(database); + }); + }); + + describe('getPreviews', () => { + let database; + let thumbnails; + let getPreviews; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + thumbnails = [{ values: [0, 1, 2] }]; + + balsamiqViewer = { + database, + }; + + spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString()); + database.exec.and.returnValue(thumbnails); + + getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); + }); + + it('should call .parsePreview for each value', () => { + const allArgs = BalsamiqViewer.parsePreview.calls.allArgs(); + + expect(allArgs.length).toBe(3); + + thumbnails[0].values.forEach((value, i) => { + expect(allArgs[i][0]).toBe(value); + }); + }); + + it('should return an array of parsed values', () => { + expect(getPreviews).toEqual(['0', '1', '2']); + }); + }); + + describe('getResource', () => { + let database; + let resourceID; + let resource; + let getResource; + + beforeEach(() => { + database = jasmine.createSpyObj('database', ['exec']); + resourceID = 4; + resource = ['resource']; + + balsamiqViewer = { + database, + }; + + database.exec.and.returnValue(resource); + + getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); + }); + + it('should call database.exec', () => { + expect(database.exec).toHaveBeenCalledWith(`SELECT * FROM resources WHERE id = '${resourceID}'`); + }); + + it('should return the selected resource', () => { + expect(getResource).toBe(resource[0]); + }); + }); + + describe('renderPreview', () => { + let previewElement; + let innerHTML; + let preview; + let renderPreview; + + beforeEach(() => { + innerHTML = '<a>innerHTML</a>'; + previewElement = { + outerHTML: '<p>outerHTML</p>', + classList: jasmine.createSpyObj('classList', ['add']), + }; + preview = {}; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']); + + spyOn(document, 'createElement').and.returnValue(previewElement); + balsamiqViewer.renderTemplate.and.returnValue(innerHTML); + + renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); + }); + + it('should call classList.add', () => { + expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); + }); + + it('should call .renderTemplate', () => { + expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); + }); + + it('should set .innerHTML', () => { + expect(previewElement.innerHTML).toBe(innerHTML); + }); + + it('should return element', () => { + expect(renderPreview).toBe(previewElement); + }); + }); + + describe('renderTemplate', () => { + let preview; + let name; + let resource; + let template; + let renderTemplate; + + beforeEach(() => { + preview = { resourceID: 1, image: 'image' }; + name = 'name'; + resource = 'resource'; + template = ` + <div class="panel panel-default"> + <div class="panel-heading">name</div> + <div class="panel-body"> + <img class="img-thumbnail" src="data:image/png;base64,image"/> + </div> + </div> + `; + + balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']); + + spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name); + balsamiqViewer.getResource.and.returnValue(resource); + + renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); + }); + + it('should call .getResource', () => { + expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); + }); + + it('should call .parseTitle', () => { + expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); + }); + + it('should return the template string', function () { + expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); + }); + }); + + describe('parsePreview', () => { + let preview; + let parsePreview; + + beforeEach(() => { + preview = ['{}', '{ "id": 1 }']; + + spyOn(JSON, 'parse').and.callThrough(); + + parsePreview = BalsamiqViewer.parsePreview(preview); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should return the parsed JSON', () => { + expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); + }); + }); + + describe('parseTitle', () => { + let title; + let parseTitle; + + beforeEach(() => { + title = { values: [['{}', '{}', '{"name":"name"}']] }; + + spyOn(JSON, 'parse').and.callThrough(); + + parseTitle = BalsamiqViewer.parseTitle(title); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); + + it('should return the name value', () => { + expect(parseTitle).toBe('name'); + }); + }); + + describe('onError', () => { + beforeEach(() => { + spyOn(window, 'Flash'); + + BalsamiqViewer.onError(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'onError'); + + it('should instantiate Flash', () => { + expect(window.Flash).toHaveBeenCalledWith('Balsamiq file could not be loaded.'); + }); + }); +}); diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index de072e7e470..376e706d1db 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -1,12 +1,12 @@ /* global List */ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global boardsMockInterceptor */ /* global BoardService */ import Vue from 'vue'; -import '~/boards/models/user'; +import '~/boards/models/assignee'; require('~/boards/models/list'); require('~/boards/models/label'); @@ -133,12 +133,12 @@ describe('Issue card', () => { }); it('does not set detail issue if img is clicked', (done) => { - vm.issue.assignee = new ListUser({ + vm.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', avatar: 'test_image', - }); + })]; Vue.nextTick(() => { triggerEvent('mouseup', vm.$el.querySelector('img')); diff --git a/spec/javascripts/boards/board_list_spec.js b/spec/javascripts/boards/board_list_spec.js index 3f598887603..a89be911667 100644 --- a/spec/javascripts/boards/board_list_spec.js +++ b/spec/javascripts/boards/board_list_spec.js @@ -35,6 +35,7 @@ describe('Board list component', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); list.issuesSize = 1; list.issues.push(issue); diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index b55ff2f473a..5ea160b7790 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; import Cookies from 'js-cookie'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Store', () => { beforeEach(() => { @@ -212,7 +212,8 @@ describe('Store', () => { title: 'Testing', iid: 2, confidential: false, - labels: [] + labels: [], + assignees: [], }); const list = gl.issueBoards.BoardsStore.addList(listObj); diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index ef567635d48..fddde799d01 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -1,20 +1,20 @@ -/* global ListUser */ +/* global ListAssignee */ /* global ListLabel */ /* global listObj */ /* global ListIssue */ import Vue from 'vue'; -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/boards_store'); -require('~/boards/components/issue_card_inner'); -require('./mock_data'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/boards_store'; +import '~/boards/components/issue_card_inner'; +import './mock_data'; describe('Issue card component', () => { - const user = new ListUser({ + const user = new ListAssignee({ id: 1, name: 'testing 123', username: 'test', @@ -40,6 +40,7 @@ describe('Issue card component', () => { iid: 1, confidential: false, labels: [list.label], + assignees: [], }); component = new Vue({ @@ -92,12 +93,12 @@ describe('Issue card component', () => { it('renders confidential icon', (done) => { component.issue.confidential = true; - setTimeout(() => { + Vue.nextTick(() => { expect( component.$el.querySelector('.confidential-icon'), ).not.toBeNull(); done(); - }, 0); + }); }); it('renders issue ID with #', () => { @@ -109,34 +110,32 @@ describe('Issue card component', () => { describe('assignee', () => { it('does not render assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).toBeNull(); }); describe('exists', () => { beforeEach((done) => { - component.issue.assignee = user; + component.issue.assignees = [user]; - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('renders assignee', () => { expect( - component.$el.querySelector('.card-assignee'), + component.$el.querySelector('.card-assignee .avatar'), ).not.toBeNull(); }); it('sets title', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('title'), + component.$el.querySelector('.card-assignee a').getAttribute('title'), ).toContain(`Assigned to ${user.name}`); }); it('sets users path', () => { expect( - component.$el.querySelector('.card-assignee').getAttribute('href'), + component.$el.querySelector('.card-assignee a').getAttribute('href'), ).toBe('/test'); }); @@ -149,11 +148,11 @@ describe('Issue card component', () => { describe('assignee default avatar', () => { beforeEach((done) => { - component.issue.assignee = new ListUser({ + component.issue.assignees = [new ListAssignee({ id: 1, name: 'testing 123', username: 'test', - }, 'default_avatar'); + }, 'default_avatar')]; Vue.nextTick(done); }); @@ -169,6 +168,75 @@ describe('Issue card component', () => { }); }); + describe('multiple assignees', () => { + beforeEach((done) => { + component.issue.assignees = [ + user, + new ListAssignee({ + id: 2, + name: 'user2', + username: 'user2', + avatar: 'test_image', + }), + new ListAssignee({ + id: 3, + name: 'user3', + username: 'user3', + avatar: 'test_image', + }), + new ListAssignee({ + id: 4, + name: 'user4', + username: 'user4', + avatar: 'test_image', + })]; + + Vue.nextTick(() => done()); + }); + + it('renders all four assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(4); + }); + + describe('more than four assignees', () => { + beforeEach((done) => { + component.issue.assignees.push(new ListAssignee({ + id: 5, + name: 'user5', + username: 'user5', + avatar: 'test_image', + })); + + Vue.nextTick(() => done()); + }); + + it('renders more avatar counter', () => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('+2'); + }); + + it('renders three assignees', () => { + expect(component.$el.querySelectorAll('.card-assignee .avatar').length).toEqual(3); + }); + + it('renders 99+ avatar counter', (done) => { + for (let i = 5; i < 104; i += 1) { + const u = new ListAssignee({ + id: i, + name: 'name', + username: 'username', + avatar: 'test_image', + }); + component.issue.assignees.push(u); + } + + Vue.nextTick(() => { + expect(component.$el.querySelector('.card-assignee .avatar-counter').innerText).toEqual('99+'); + done(); + }); + }); + }); + }); + describe('labels', () => { it('does not render any', () => { expect( @@ -180,9 +248,7 @@ describe('Issue card component', () => { beforeEach((done) => { component.issue.addLabel(label1); - setTimeout(() => { - done(); - }, 0); + Vue.nextTick(() => done()); }); it('does not render list label', () => { diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js index c96dfe94a4a..cd1497bc5e6 100644 --- a/spec/javascripts/boards/issue_spec.js +++ b/spec/javascripts/boards/issue_spec.js @@ -2,14 +2,15 @@ /* global BoardService */ /* global ListIssue */ -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import Vue from 'vue'; +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('Issue model', () => { let issue; @@ -27,7 +28,13 @@ describe('Issue model', () => { title: 'test', color: 'red', description: 'testing' - }] + }], + assignees: [{ + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }], }); }); @@ -80,6 +87,33 @@ describe('Issue model', () => { expect(issue.labels.length).toBe(0); }); + it('adds assignee', () => { + issue.addAssignee({ + id: 2, + name: 'Bruce Wayne', + username: 'batman', + avatar_url: 'http://batman', + }); + + expect(issue.assignees.length).toBe(2); + }); + + it('finds assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + expect(assignee).toBeDefined(); + }); + + it('removes assignee', () => { + const assignee = issue.findAssignee(issue.assignees[0]); + issue.removeAssignee(assignee); + expect(issue.assignees.length).toBe(0); + }); + + it('removes all assignees', () => { + issue.removeAllAssignees(); + expect(issue.assignees.length).toBe(0); + }); + it('sets position to infinity if no position is stored', () => { expect(issue.position).toBe(Infinity); }); @@ -90,9 +124,31 @@ describe('Issue model', () => { iid: 1, confidential: false, relative_position: 1, - labels: [] + labels: [], + assignees: [], }); expect(relativePositionIssue.position).toBe(1); }); + + describe('update', () => { + it('passes assignee ids when there are assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([1]); + done(); + }); + + issue.update('url'); + }); + + it('passes assignee ids of [0] when there are no assignees', (done) => { + spyOn(Vue.http, 'patch').and.callFake((url, data) => { + expect(data.issue.assignee_ids).toEqual([0]); + done(); + }); + + issue.removeAllAssignees(); + issue.update('url'); + }); + }); }); diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 24a2da9f6b6..8e3d9fd77a0 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -8,14 +8,14 @@ import Vue from 'vue'; -require('~/lib/utils/url_utility'); -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/services/board_service'); -require('~/boards/stores/boards_store'); -require('./mock_data'); +import '~/lib/utils/url_utility'; +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/services/board_service'; +import '~/boards/stores/boards_store'; +import './mock_data'; describe('List model', () => { let list; @@ -94,7 +94,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label, listDup.label] + labels: [list.label, listDup.label], + assignees: [], }); list.issues.push(issue); @@ -119,7 +120,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000) + i, confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); } list.issuesSize = 50; @@ -137,7 +139,8 @@ describe('List model', () => { title: 'Testing', iid: _.random(10000), confidential: false, - labels: [list.label] + labels: [list.label], + assignees: [], })); list.issuesSize = 2; diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js index a4fa694eebe..a64c3964ee3 100644 --- a/spec/javascripts/boards/mock_data.js +++ b/spec/javascripts/boards/mock_data.js @@ -33,7 +33,8 @@ const BoardsMockData = { title: 'Testing', iid: 1, confidential: false, - labels: [] + labels: [], + assignees: [], }], size: 1 } diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index 80db816aff8..32e6d04df9f 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -1,10 +1,10 @@ /* global ListIssue */ -require('~/boards/models/issue'); -require('~/boards/models/label'); -require('~/boards/models/list'); -require('~/boards/models/user'); -require('~/boards/stores/modal_store'); +import '~/boards/models/issue'; +import '~/boards/models/label'; +import '~/boards/models/list'; +import '~/boards/models/assignee'; +import '~/boards/stores/modal_store'; describe('Modal store', () => { let issue; @@ -21,12 +21,14 @@ describe('Modal store', () => { iid: 1, confidential: false, labels: [], + assignees: [], }); issue2 = new ListIssue({ title: 'Testing', iid: 2, confidential: false, labels: [], + assignees: [], }); Store.store.issues.push(issue); Store.store.issues.push(issue2); diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index 2722882375f..d0f09a561d5 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -76,6 +76,26 @@ describe('RecentSearchesDropdownContent', () => { }); }); + 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', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index e747aa497c2..063d547d00c 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -1,3 +1,7 @@ +import * as recentSearchesStoreSrc from '~/filtered_search/stores/recent_searches_store'; +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + require('~/lib/utils/url_utility'); require('~/lib/utils/common_utils'); require('~/filtered_search/filtered_search_token_keys'); @@ -60,6 +64,36 @@ describe('Filtered Search Manager', () => { manager.cleanup(); }); + describe('class constructor', () => { + const isLocalStorageAvailable = 'isLocalStorageAvailable'; + let filteredSearchManager; + + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(isLocalStorageAvailable); + spyOn(recentSearchesStoreSrc, 'default'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + return filteredSearchManager; + }); + + it('should instantiate RecentSearchesStore with isLocalStorageAvailable', () => { + expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); + expect(recentSearchesStoreSrc.default).toHaveBeenCalledWith({ + isLocalStorageAvailable, + }); + }); + + it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + spyOn(RecentSearchesService.prototype, 'fetch').and.callFake(() => Promise.reject(new RecentSearchesServiceError())); + spyOn(window, 'Flash'); + + filteredSearchManager = new gl.FilteredSearchManager(); + + expect(window.Flash).not.toHaveBeenCalled(); + }); + }); + describe('search', () => { const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index d75b9061281..8b750561eb7 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,3 +1,5 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + require('~/filtered_search/filtered_search_visual_tokens'); const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); @@ -611,4 +613,103 @@ describe('Filtered Search Visual Tokens', () => { expect(token.querySelector('.value').innerText).toEqual('~bug'); }); }); + + describe('renderVisualTokenValue', () => { + let searchTokens; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + `); + + searchTokens = document.querySelectorAll('.filtered-search-token'); + }); + + it('renders a token value element', () => { + spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor'); + const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor; + + expect(searchTokens.length).toBe(2); + Array.prototype.forEach.call(searchTokens, (token) => { + updateLabelTokenColorSpy.calls.reset(); + + const tokenName = token.querySelector('.name').innerText; + const tokenValue = 'new value'; + gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue); + + const tokenValueElement = token.querySelector('.value'); + expect(tokenValueElement.innerText).toBe(tokenValue); + + if (tokenName.toLowerCase() === 'label') { + const tokenValueContainer = token.querySelector('.value-container'); + expect(updateLabelTokenColorSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValue]; + expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); + } else { + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + } + }); + }); + }); + + describe('updateLabelTokenColor', () => { + const jsonFixtureName = 'labels/project_labels.json'; + const dummyEndpoint = '/dummy/endpoint'; + + preloadFixtures(jsonFixtureName); + const labelData = getJSONFixture(jsonFixtureName); + const findLabel = tokenValue => labelData.find( + label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, + ); + + const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist'); + const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"'); + + const parseColor = (color) => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${bugLabelToken.outerHTML} + ${missingLabelToken.outerHTML} + ${spaceLabelToken.outerHTML} + `); + + const filteredSearchInput = document.querySelector('.filtered-search'); + filteredSearchInput.dataset.baseEndpoint = dummyEndpoint; + + AjaxCache.internalStorage = { }; + AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData; + }); + + const testCase = (token, done) => { + const tokenValueContainer = token.querySelector('.value-container'); + const tokenValue = token.querySelector('.value').innerText; + const label = findLabel(tokenValue); + + gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + if (label) { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); + } else { + expect(token).toBe(missingLabelToken); + expect(tokenValueContainer.getAttribute('style')).toBe(null); + } + }) + .then(done) + .catch(fail); + }; + + it('updates the color of a label token', done => testCase(bugLabelToken, done)); + it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done)); + it('does not change color of a missing label', done => testCase(missingLabelToken, done)); + }); }); diff --git a/spec/javascripts/filtered_search/recent_searches_root_spec.js b/spec/javascripts/filtered_search/recent_searches_root_spec.js new file mode 100644 index 00000000000..d8ba6de5f45 --- /dev/null +++ b/spec/javascripts/filtered_search/recent_searches_root_spec.js @@ -0,0 +1,31 @@ +import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; +import * as vueSrc from 'vue'; + +describe('RecentSearchesRoot', () => { + describe('render', () => { + let recentSearchesRoot; + let data; + let template; + + beforeEach(() => { + recentSearchesRoot = { + store: { + state: 'state', + }, + }; + + spyOn(vueSrc, 'default').and.callFake((options) => { + data = options.data; + template = options.template; + }); + + RecentSearchesRoot.prototype.render.call(recentSearchesRoot); + }); + + it('should instantiate Vue', () => { + expect(vueSrc.default).toHaveBeenCalled(); + expect(data()).toBe(recentSearchesRoot.store.state); + expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"'); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js new file mode 100644 index 00000000000..ea7c146fa4f --- /dev/null +++ b/spec/javascripts/filtered_search/services/recent_searches_service_error_spec.js @@ -0,0 +1,18 @@ +import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; + +describe('RecentSearchesServiceError', () => { + let recentSearchesServiceError; + + beforeEach(() => { + recentSearchesServiceError = new RecentSearchesServiceError(); + }); + + it('instantiates an instance of RecentSearchesServiceError and not an Error', () => { + expect(recentSearchesServiceError).toEqual(jasmine.any(RecentSearchesServiceError)); + expect(recentSearchesServiceError.name).toBe('RecentSearchesServiceError'); + }); + + it('should set a default message', () => { + expect(recentSearchesServiceError.message).toBe('Recent Searches Service is unavailable'); + }); +}); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js index c255bf7c939..31fa478804a 100644 --- a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -1,6 +1,7 @@ /* eslint-disable promise/catch-or-return */ import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; +import AccessorUtilities from '~/lib/utils/accessor'; describe('RecentSearchesService', () => { let service; @@ -11,6 +12,10 @@ describe('RecentSearchesService', () => { }); describe('fetch', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should default to empty array', (done) => { const fetchItemsPromise = service.fetch(); @@ -29,11 +34,21 @@ describe('RecentSearchesService', () => { const fetchItemsPromise = service.fetch(); fetchItemsPromise - .catch(() => { + .catch((error) => { + expect(error).toEqual(jasmine.any(SyntaxError)); done(); }); }); + it('should reject when service is unavailable', (done) => { + RecentSearchesService.isAvailable.and.returnValue(false); + + service.fetch().catch((error) => { + expect(error).toEqual(jasmine.any(Error)); + done(); + }); + }); + it('should return items from localStorage', (done) => { window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]'); const fetchItemsPromise = service.fetch(); @@ -44,15 +59,89 @@ describe('RecentSearchesService', () => { done(); }); }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + spyOn(window.localStorage, 'getItem'); + + RecentSearchesService.prototype.fetch(); + }); + + it('should not call .getItem', () => { + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); }); describe('setRecentSearches', () => { + beforeEach(() => { + spyOn(RecentSearchesService, 'isAvailable').and.returnValue(true); + }); + it('should save things in localStorage', () => { const items = ['foo', 'bar']; service.save(items); - const newLocalStorageValue = - window.localStorage.getItem(service.localStorageKey); + const newLocalStorageValue = window.localStorage.getItem(service.localStorageKey); expect(JSON.parse(newLocalStorageValue)).toEqual(items); }); }); + + describe('save', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(RecentSearchesService, 'isAvailable'); + }); + + describe('if .isAvailable returns `true`', () => { + const searchesString = 'searchesString'; + const localStorageKey = 'localStorageKey'; + const recentSearchesService = { + localStorageKey, + }; + + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(true); + + spyOn(JSON, 'stringify').and.returnValue(searchesString); + + RecentSearchesService.prototype.save.call(recentSearchesService); + }); + + it('should call .setItem', () => { + expect(window.localStorage.setItem).toHaveBeenCalledWith(localStorageKey, searchesString); + }); + }); + + describe('if .isAvailable returns `false`', () => { + beforeEach(() => { + RecentSearchesService.isAvailable.and.returnValue(false); + + RecentSearchesService.prototype.save(); + }); + + it('should not call .setItem', () => { + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('isAvailable', () => { + let isAvailable; + + beforeEach(() => { + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.callThrough(); + + isAvailable = RecentSearchesService.isAvailable(); + }); + + it('should call .isLocalStorageAccessSafe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + }); + + it('should return a boolean', () => { + expect(typeof isAvailable).toBe('boolean'); + }); + }); }); diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb new file mode 100644 index 00000000000..2e4811b64a4 --- /dev/null +++ b/spec/javascripts/fixtures/labels.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'Labels (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:group) { create(:group, name: 'frontend-fixtures-group' )} + let(:project) { create(:project_empty_repo, namespace: group, path: 'labels-project') } + + let!(:project_label_bug) { create(:label, project: project, title: 'bug', color: '#FF0000') } + let!(:project_label_enhancement) { create(:label, project: project, title: 'enhancement', color: '#00FF00') } + let!(:project_label_feature) { create(:label, project: project, title: 'feature', color: '#0000FF') } + + let!(:group_label_roses) { create(:group_label, group: group, title: 'roses', color: '#FF0000') } + let!(:groub_label_space) { create(:group_label, group: group, title: 'some space', color: '#FFFFFF') } + let!(:groub_label_violets) { create(:group_label, group: group, title: 'violets', color: '#0000FF') } + + before(:all) do + clean_frontend_fixtures('labels/') + end + + describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/group_labels.json' do |example| + get :index, + group_id: group, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end + + describe Projects::LabelsController, '(JavaScript fixtures)', type: :controller do + render_views + + before(:each) do + sign_in(admin) + end + + it 'labels/project_labels.json' do |example| + get :index, + namespace_id: group, + project_id: project, + format: 'json' + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + end +end diff --git a/spec/javascripts/helpers/user_mock_data_helper.js b/spec/javascripts/helpers/user_mock_data_helper.js new file mode 100644 index 00000000000..a9783ea065c --- /dev/null +++ b/spec/javascripts/helpers/user_mock_data_helper.js @@ -0,0 +1,16 @@ +export default { + createNumberRandomUsers(numberUsers) { + const users = []; + for (let i = 0; i < numberUsers; i = i += 1) { + users.push( + { + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: (i + 1), + name: `GitLab User ${i}`, + username: `gitlab${i}`, + }, + ); + } + return users; + }, +}; diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js index 0a830f25e29..8ff93c4f918 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js +++ b/spec/javascripts/issuable_time_tracker_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; -require('~/issuable/time_tracking/components/time_tracker'); +import timeTracker from '~/sidebar/components/time_tracking/time_tracker'; function initTimeTrackingComponent(opts) { setFixtures(` @@ -16,187 +16,185 @@ function initTimeTrackingComponent(opts) { time_spent: opts.timeSpent, human_time_estimate: opts.timeEstimateHumanReadable, human_time_spent: opts.timeSpentHumanReadable, - docsUrl: '/help/workflow/time_tracking.md', + rootPath: '/', }; - const TimeTrackingComponent = Vue.component('issuable-time-tracker'); + const TimeTrackingComponent = Vue.extend(timeTracker); this.timeTracker = new TimeTrackingComponent({ el: '#mock-container', propsData: this.initialData, }); } -((gl) => { - describe('Issuable Time Tracker', function() { - describe('Initialization', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); +describe('Issuable Time Tracker', function() { + describe('Initialization', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should return something defined', function() { - expect(this.timeTracker).toBeDefined(); - }); + it('should return something defined', function() { + expect(this.timeTracker).toBeDefined(); + }); - it ('should correctly set timeEstimate', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); - done(); - }); + it ('should correctly set timeEstimate', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate); + done(); }); - it ('should correctly set time_spent', function(done) { - Vue.nextTick(() => { - expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); - done(); - }); + }); + it ('should correctly set time_spent', function(done) { + Vue.nextTick(() => { + expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent); + done(); }); }); + }); - describe('Content Display', function() { - describe('Panes', function() { - describe('Comparison pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + describe('Content Display', function() { + describe('Panes', function() { + describe('Comparison pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); + + it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + Vue.nextTick(() => { + const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); + expect(this.timeTracker.showComparisonState).toBe(true); + done(); }); + }); - it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) { + describe('Remaining meter', function() { + it('should display the remaining meter with the correct width', function(done) { Vue.nextTick(() => { - const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane'); - expect(this.timeTracker.showComparisonState).toBe(true); + const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; + const correctWidth = '5%'; + + expect(meterWidth).toBe(correctWidth); done(); - }); + }) }); - describe('Remaining meter', function() { - it('should display the remaining meter with the correct width', function(done) { - Vue.nextTick(() => { - const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width; - const correctWidth = '5%'; - - expect(meterWidth).toBe(correctWidth); - done(); - }) - }); - - it('should display the remaining meter with the correct background color when within estimate', function(done) { - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done() - }); + it('should display the remaining meter with the correct background color when within estimate', function(done) { + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done() }); + }); - it('should display the remaining meter with the correct background color when over estimate', function(done) { - this.timeTracker.time_estimate = 100000; - this.timeTracker.time_spent = 20000000; - Vue.nextTick(() => { - const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); - expect(styledMeter.length).toBe(1); - done(); - }); + it('should display the remaining meter with the correct background color when over estimate', function(done) { + this.timeTracker.time_estimate = 100000; + this.timeTracker.time_spent = 20000000; + Vue.nextTick(() => { + const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill'); + expect(styledMeter.length).toBe(1); + done(); }); }); }); + }); - describe("Estimate only pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); - }); + describe("Estimate only pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' }); + }); - it('should display the human readable version of time estimated', function(done) { - Vue.nextTick(() => { - const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; - const correctText = 'Estimated: 2h 46m'; + it('should display the human readable version of time estimated', function(done) { + Vue.nextTick(() => { + const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText; + const correctText = 'Estimated: 2h 46m'; - expect(estimateText).toBe(correctText); - done(); - }); + expect(estimateText).toBe(correctText); + done(); }); }); + }); - describe('Spent only pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); - }); + describe('Spent only pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' }); + }); - it('should display the human readable version of time spent', function(done) { - Vue.nextTick(() => { - const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; - const correctText = 'Spent: 1h 23m'; + it('should display the human readable version of time spent', function(done) { + Vue.nextTick(() => { + const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText; + const correctText = 'Spent: 1h 23m'; - expect(spentText).toBe(correctText); - done(); - }); + expect(spentText).toBe(correctText); + done(); }); }); + }); - describe('No time tracking pane', function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 }); - }); + describe('No time tracking pane', function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' }); + }); - it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { - Vue.nextTick(() => { - const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); - const noTrackingText =$noTrackingPane.innerText; - const correctText = 'No estimate or time spent'; + it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) { + Vue.nextTick(() => { + const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane'); + const noTrackingText =$noTrackingPane.innerText; + const correctText = 'No estimate or time spent'; - expect(this.timeTracker.showNoTimeTrackingState).toBe(true); - expect($noTrackingPane).toBeVisible(); - expect(noTrackingText).toBe(correctText); - done(); - }); + expect(this.timeTracker.showNoTimeTrackingState).toBe(true); + expect($noTrackingPane).toBeVisible(); + expect(noTrackingText).toBe(correctText); + done(); }); }); + }); - describe("Help pane", function() { - beforeEach(function() { - initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); - }); + describe("Help pane", function() { + beforeEach(function() { + initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 }); + }); - it('should not show the "Help" pane by default', function(done) { - Vue.nextTick(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + it('should not show the "Help" pane by default', function(done) { + Vue.nextTick(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); - done(); - }); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); + done(); }); + }); - it('should show the "Help" pane when help button is clicked', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should show the "Help" pane when help button is clicked', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(true); - expect($helpPane).toBeVisible(); - done(); - }, 10); - }); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + expect(this.timeTracker.showHelpState).toBe(true); + expect($helpPane).toBeVisible(); + done(); + }, 10); }); + }); - it('should not show the "Help" pane when help button is clicked and then closed', function(done) { - Vue.nextTick(() => { - $(this.timeTracker.$el).find('.help-button').click(); + it('should not show the "Help" pane when help button is clicked and then closed', function(done) { + Vue.nextTick(() => { + $(this.timeTracker.$el).find('.help-button').click(); - setTimeout(() => { + setTimeout(() => { - $(this.timeTracker.$el).find('.close-help-button').click(); + $(this.timeTracker.$el).find('.close-help-button').click(); - setTimeout(() => { - const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); + setTimeout(() => { + const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); - expect(this.timeTracker.showHelpState).toBe(false); - expect($helpPane).toBeNull(); + expect(this.timeTracker.showHelpState).toBe(false); + expect($helpPane).toBeNull(); - done(); - }, 1000); + done(); }, 1000); - }); + }, 1000); }); }); }); }); }); -})(window.gl || (window.gl = {})); +}); diff --git a/spec/javascripts/issue_show/issue_title_description_spec.js b/spec/javascripts/issue_show/issue_title_description_spec.js new file mode 100644 index 00000000000..1ec4fe58b08 --- /dev/null +++ b/spec/javascripts/issue_show/issue_title_description_spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import $ from 'jquery'; +import '~/render_math'; +import '~/render_gfm'; +import issueTitleDescription from '~/issue_show/issue_title_description.vue'; +import issueShowData from './mock_data'; + +window.$ = $; + +const issueShowInterceptor = data => (request, next) => { + next(request.respondWith(JSON.stringify(data), { + status: 200, + headers: { + 'POLL-INTERVAL': 1, + }, + })); +}; + +describe('Issue Title', () => { + document.body.innerHTML = '<span id="task_status"></span>'; + + let IssueTitleDescriptionComponent; + + beforeEach(() => { + IssueTitleDescriptionComponent = Vue.extend(issueTitleDescription); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); + }); + + it('should render a title/description and update title/description on update', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + const issueShowComponent = new IssueTitleDescriptionComponent({ + propsData: { + canUpdateIssue: '.css-stuff', + endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', + }, + }).$mount(); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('this is a description'); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + setTimeout(() => { + expect(document.querySelector('title').innerText).toContain('2 (#1)'); + expect(issueShowComponent.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); + expect(issueShowComponent.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); + expect(issueShowComponent.$el.querySelector('.js-task-list-field').innerText).toContain('42'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/issue_title_spec.js b/spec/javascripts/issue_show/issue_title_spec.js deleted file mode 100644 index 03edbf9f947..00000000000 --- a/spec/javascripts/issue_show/issue_title_spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import Vue from 'vue'; -import issueTitle from '~/issue_show/issue_title.vue'; - -describe('Issue Title', () => { - let IssueTitleComponent; - - beforeEach(() => { - IssueTitleComponent = Vue.extend(issueTitle); - }); - - it('should render a title', () => { - const component = new IssueTitleComponent({ - propsData: { - initialTitle: 'wow', - endpoint: '/gitlab-org/gitlab-shell/issues/9/rendered_title', - }, - }).$mount(); - - expect(component.$el.classList).toContain('title'); - expect(component.$el.innerHTML).toContain('wow'); - }); -}); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js new file mode 100644 index 00000000000..ad5a7b63470 --- /dev/null +++ b/spec/javascripts/issue_show/mock_data.js @@ -0,0 +1,26 @@ +export default { + initialRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<p>this is a description!</p>', + description_text: 'this is a description', + issue_number: 1, + task_status: '2 of 4 completed', + }, + secondRequest: { + title: '<p>2</p>', + title_text: '2', + description: '<p>42</p>', + description_text: '42', + issue_number: 1, + task_status: '0 of 0 completed', + }, + issueSpecRequest: { + title: '<p>this is a title</p>', + title_text: 'this is a title', + description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', + description_text: '- [ ] Task List Item', + issue_number: 1, + task_status: '0 of 1 completed', + }, +}; diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 0fd573eae3f..763f5ee9e50 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -81,12 +81,6 @@ describe('Issue', function() { this.issue = new Issue(); }); - it('modifies the Markdown field', function() { - spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); - }); - it('submits an ajax request on tasklist:changed', function() { spyOn(jQuery, 'ajax').and.callFake(function(req) { expect(req.type).toBe('PATCH'); diff --git a/spec/javascripts/lib/utils/accessor_spec.js b/spec/javascripts/lib/utils/accessor_spec.js new file mode 100644 index 00000000000..b768d6f2a68 --- /dev/null +++ b/spec/javascripts/lib/utils/accessor_spec.js @@ -0,0 +1,78 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + +describe('AccessorUtilities', () => { + const testError = new Error('test error'); + + describe('isPropertyAccessSafe', () => { + let base; + + it('should return `true` if access is safe', () => { + base = { testProp: 'testProp' }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(true); + }); + + it('should return `false` if access throws an error', () => { + base = { get testProp() { throw testError; } }; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + + it('should return `false` if property is undefined', () => { + base = {}; + + expect(AccessorUtilities.isPropertyAccessSafe(base, 'testProp')).toBe(false); + }); + }); + + describe('isFunctionCallSafe', () => { + const base = {}; + + it('should return `true` if calling is safe', () => { + base.func = () => {}; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(true); + }); + + it('should return `false` if calling throws an error', () => { + base.func = () => { throw new Error('test error'); }; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + + it('should return `false` if function is undefined', () => { + base.func = undefined; + + expect(AccessorUtilities.isFunctionCallSafe(base, 'func')).toBe(false); + }); + }); + + describe('isLocalStorageAccessSafe', () => { + beforeEach(() => { + spyOn(window.localStorage, 'setItem'); + spyOn(window.localStorage, 'removeItem'); + }); + + it('should return `true` if access is safe', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(true); + }); + + it('should return `false` if access to .setItem isnt safe', () => { + window.localStorage.setItem.and.callFake(() => { throw testError; }); + + expect(AccessorUtilities.isLocalStorageAccessSafe()).toBe(false); + }); + + it('should set a test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.setItem).toHaveBeenCalledWith('isLocalStorageAccessSafe', 'true'); + }); + + it('should remove the test item if access is safe', () => { + AccessorUtilities.isLocalStorageAccessSafe(); + + expect(window.localStorage.removeItem).toHaveBeenCalledWith('isLocalStorageAccessSafe'); + }); + }); +}); diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js new file mode 100644 index 00000000000..7b466a11b92 --- /dev/null +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -0,0 +1,129 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +describe('AjaxCache', () => { + const dummyEndpoint = '/AjaxCache/dummyEndpoint'; + const dummyResponse = { + important: 'dummy data', + }; + let ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + + beforeEach(() => { + AjaxCache.internalStorage = { }; + spyOn(jQuery, 'ajax').and.callFake(url => ajaxSpy(url)); + }); + + describe('#get', () => { + it('returns undefined if cache is empty', () => { + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns undefined if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(undefined); + }); + + it('returns matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + const data = AjaxCache.get(dummyEndpoint); + + expect(data).toBe(dummyResponse); + }); + }); + + describe('#hasData', () => { + it('returns false if cache is empty', () => { + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns false if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(false); + }); + + it('returns true if data is available', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + expect(AjaxCache.hasData(dummyEndpoint)).toBe(true); + }); + }); + + describe('#purge', () => { + it('does nothing if cache is empty', () => { + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + + it('does nothing if cache contains no matching data', () => { + AjaxCache.internalStorage['not matching'] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage['not matching']).toBe(dummyResponse); + }); + + it('removes matching data', () => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + + AjaxCache.purge(dummyEndpoint); + + expect(AjaxCache.internalStorage).toEqual({ }); + }); + }); + + describe('#retrieve', () => { + it('stores and returns data from Ajax call if cache is empty', (done) => { + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + expect(AjaxCache.internalStorage[dummyEndpoint]).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + + it('returns undefined if Ajax call fails and cache is empty', (done) => { + const dummyStatusText = 'exploded'; + const dummyErrorMessage = 'server exploded'; + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.reject(null, dummyStatusText, dummyErrorMessage); + return deferred.promise(); + }; + + AjaxCache.retrieve(dummyEndpoint) + .then(data => fail(`Received unexpected data: ${JSON.stringify(data)}`)) + .catch((error) => { + expect(error.message).toBe(`${dummyEndpoint}: ${dummyErrorMessage}`); + expect(error.textStatus).toBe(dummyStatusText); + done(); + }) + .catch(fail); + }); + + it('makes no Ajax call if matching data exists', (done) => { + AjaxCache.internalStorage[dummyEndpoint] = dummyResponse; + ajaxSpy = () => fail(new Error('expected no Ajax call!')); + + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); + }); +}); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 7bffa90ab14..cfd599f793e 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -29,7 +29,7 @@ import '~/notes'; $('.js-comment-button').on('click', function(e) { e.preventDefault(); }); - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('modifies the Markdown field', function() { @@ -51,7 +51,7 @@ import '~/notes'; var textarea = '.js-note-text'; beforeEach(function() { - this.notes = new Notes(); + this.notes = new Notes('', []); this.autoSizeSpy = spyOnEvent($(textarea), 'autosize:update'); spyOn(this.notes, 'renderNote').and.stub(); @@ -273,9 +273,92 @@ import '~/notes'; }); }); + describe('postComment & updateComment', () => { + const sampleComment = 'foo'; + const updatedComment = 'bar'; + const note = { + id: 1234, + html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> + <div class="note-text">${sampleComment}</div> + </li>`, + note: sampleComment, + valid: true + }; + let $form; + let $notesContainer; + + beforeEach(() => { + this.notes = new Notes('', []); + window.gon.current_username = 'root'; + window.gon.current_user_fullname = 'Administrator'; + $form = $('form.js-main-target-form'); + $notesContainer = $('ul.main-notes-list'); + $form.find('textarea.js-note-text').val(sampleComment); + }); + + it('should show placeholder note while new comment is being posted', () => { + $('.js-comment-button').click(); + expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); + }); + + it('should remove placeholder note when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find('.note.being-posted').length).toEqual(0); + }); + + it('should show actual note element when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($notesContainer.find(`#note_${note.id}`).length > 0).toEqual(true); + }); + + it('should reset Form when new comment is done posting', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + expect($form.find('textarea.js-note-text').val()).toEqual(''); + }); + + it('should show flash error message when new comment failed to be posted', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.reject(); + expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); + }); + + it('should show flash error message when comment failed to be updated', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + $('.js-comment-button').click(); + + deferred.resolve(note); + const $noteEl = $notesContainer.find(`#note_${note.id}`); + $noteEl.find('.js-note-edit').click(); + $noteEl.find('textarea.js-note-text').val(updatedComment); + $noteEl.find('.js-comment-save-button').click(); + + deferred.reject(); + const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); + expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals + expect($updatedNoteEl.find('.note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original + expect($('.flash-container').is(':visible')).toEqual(true); // Flash error message shown + }); + }); + describe('getFormData', () => { it('should return form metadata object from form reference', () => { - this.notes = new Notes(); + this.notes = new Notes('', []); const $form = $('form'); const sampleComment = 'foobar'; @@ -290,7 +373,7 @@ import '~/notes'; describe('hasSlashCommands', () => { beforeEach(() => { - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('should return true when comment has slash commands', () => { @@ -327,7 +410,7 @@ import '~/notes'; const currentUserFullname = 'Administrator'; beforeEach(() => { - this.notes = new Notes(); + this.notes = new Notes('', []); }); it('should return constructed placeholder element for regular note based on form contents', () => { @@ -364,129 +447,5 @@ import '~/notes'; expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); }); }); - - describe('postComment & updateComment', () => { - const sampleComment = 'foo'; - const updatedComment = 'bar'; - const note = { - id: 1234, - html: `<li class="note note-row-1234 timeline-entry" id="note_1234"> - <div class="note-text">${sampleComment}</div> - </li>`, - note: sampleComment, - valid: true - }; - let $form; - let $notesContainer; - - beforeEach(() => { - this.notes = new Notes(); - window.gon.current_username = 'root'; - window.gon.current_user_fullname = 'Administrator'; - $form = $('form'); - $notesContainer = $('ul.main-notes-list'); - $form.find('textarea.js-note-text').val(sampleComment); - $('.js-comment-button').click(); - }); - - it('should show placeholder note while new comment is being posted', () => { - expect($notesContainer.find('.note.being-posted').length > 0).toEqual(true); - }); - - it('should remove placeholder note when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - expect($notesContainer.find('.note.being-posted').length).toEqual(0); - }); - }); - - it('should show actual note element when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - expect($notesContainer.find(`#${note.id}`).length > 0).toEqual(true); - }); - }); - - it('should reset Form when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - expect($form.find('textarea.js-note-text')).toEqual(''); - }); - }); - - it('should trigger ajax:success event on Form when new comment is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - spyOn($form, 'trigger'); - expect($form.trigger).toHaveBeenCalledWith('ajax:success', [note]); - }); - }); - - it('should show flash error message when new comment failed to be posted', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.error(); - expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); - }); - }); - - it('should refill form textarea with original comment content when new comment failed to be posted', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.error(); - expect($form.find('textarea.js-note-text')).toEqual(sampleComment); - }); - }); - - it('should show updated comment as _actively being posted_ while comment being updated', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - $noteEl.find('.js-comment-save-button').click(); - expect($noteEl.hasClass('.being-posted')).toEqual(true); - expect($noteEl.find('.note-text').text()).toEqual(updatedComment); - }); - }); - - it('should show updated comment when comment update is done posting', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - $noteEl.find('.js-comment-save-button').click(); - - spyOn($, 'ajax').and.callFake((updateOptions) => { - const updatedNote = Object.assign({}, note); - updatedNote.note = updatedComment; - updatedNote.html = `<li class="note note-row-1234 timeline-entry" id="note_1234"> - <div class="note-text">${updatedComment}</div> - </li>`; - updateOptions.success(updatedNote); - const $updatedNoteEl = $notesContainer.find(`#note_${updatedNote.id}`); - expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect($updatedNoteEl.find('note-text').text().trim()).toEqual(updatedComment); // Verify if comment text updated - }); - }); - }); - - it('should show flash error message when comment failed to be updated', () => { - spyOn($, 'ajax').and.callFake((options) => { - options.success(note); - const $noteEl = $notesContainer.find(`#note_${note.id}`); - $noteEl.find('.js-note-edit').click(); - $noteEl.find('textarea.js-note-text').val(updatedComment); - $noteEl.find('.js-comment-save-button').click(); - - spyOn($, 'ajax').and.callFake((updateOptions) => { - updateOptions.error(); - const $updatedNoteEl = $notesContainer.find(`#note_${note.id}`); - expect($updatedNoteEl.hasClass('.being-posted')).toEqual(false); // Remove being-posted visuals - expect($updatedNoteEl.find('note-text').text().trim()).toEqual(sampleComment); // See if comment reverted back to original - expect($notesContainer.parent().find('.flash-container .flash-text').is(':visible')).toEqual(true); // Flash error message shown - }); - }); - }); - }); }); }).call(window); diff --git a/spec/javascripts/raven/index_spec.js b/spec/javascripts/raven/index_spec.js new file mode 100644 index 00000000000..b5662cd0331 --- /dev/null +++ b/spec/javascripts/raven/index_spec.js @@ -0,0 +1,42 @@ +import RavenConfig from '~/raven/raven_config'; +import index from '~/raven/index'; + +describe('RavenConfig options', () => { + let sentryDsn; + let currentUserId; + let gitlabUrl; + let isProduction; + let indexReturnValue; + + beforeEach(() => { + sentryDsn = 'sentryDsn'; + currentUserId = 'currentUserId'; + gitlabUrl = 'gitlabUrl'; + isProduction = 'isProduction'; + + window.gon = { + sentry_dsn: sentryDsn, + current_user_id: currentUserId, + gitlab_url: gitlabUrl, + }; + + process.env.NODE_ENV = isProduction; + + spyOn(RavenConfig, 'init'); + + indexReturnValue = index(); + }); + + it('should init with .sentryDsn, .currentUserId, .whitelistUrls and .isProduction', () => { + expect(RavenConfig.init).toHaveBeenCalledWith({ + sentryDsn, + currentUserId, + whitelistUrls: [gitlabUrl], + isProduction, + }); + }); + + it('should return RavenConfig', () => { + expect(indexReturnValue).toBe(RavenConfig); + }); +}); diff --git a/spec/javascripts/raven/raven_config_spec.js b/spec/javascripts/raven/raven_config_spec.js new file mode 100644 index 00000000000..a2d720760fc --- /dev/null +++ b/spec/javascripts/raven/raven_config_spec.js @@ -0,0 +1,276 @@ +import Raven from 'raven-js'; +import RavenConfig from '~/raven/raven_config'; + +describe('RavenConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = RavenConfig.IGNORE_ERRORS.every(error => typeof error === 'string'); + + expect(areStrings).toBe(true); + }); + }); + + describe('IGNORE_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = RavenConfig.IGNORE_URLS.every(url => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof RavenConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + + describe('init', () => { + let options; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: 1, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + spyOn(RavenConfig, 'configure'); + spyOn(RavenConfig, 'bindRavenErrors'); + spyOn(RavenConfig, 'setUser'); + + RavenConfig.init(options); + }); + + it('should set the options property', () => { + expect(RavenConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(RavenConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(RavenConfig.bindRavenErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(RavenConfig.setUser).toHaveBeenCalled(); + }); + + it('should not call setUser if there is no current user ID', () => { + RavenConfig.setUser.calls.reset(); + + RavenConfig.init({ + sentryDsn: '//sentryDsn', + ravenAssetUrl: '//ravenAssetUrl', + currentUserId: undefined, + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }); + + expect(RavenConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + let options; + let raven; + let ravenConfig; + + beforeEach(() => { + options = { + sentryDsn: '//sentryDsn', + whitelistUrls: ['//gitlabUrl'], + isProduction: true, + }; + + ravenConfig = jasmine.createSpyObj('ravenConfig', ['shouldSendSample']); + raven = jasmine.createSpyObj('raven', ['install']); + + spyOn(Raven, 'config').and.returnValue(raven); + + ravenConfig.options = options; + ravenConfig.IGNORE_ERRORS = 'ignore_errors'; + ravenConfig.IGNORE_URLS = 'ignore_urls'; + + RavenConfig.configure.call(ravenConfig); + }); + + it('should call Raven.config', () => { + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'production', + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), + }); + }); + + it('should call Raven.install', () => { + expect(raven.install).toHaveBeenCalled(); + }); + + it('should set .environment to development if isProduction is false', () => { + ravenConfig.options.isProduction = false; + + RavenConfig.configure.call(ravenConfig); + + expect(Raven.config).toHaveBeenCalledWith(options.sentryDsn, { + whitelistUrls: options.whitelistUrls, + environment: 'development', + ignoreErrors: ravenConfig.IGNORE_ERRORS, + ignoreUrls: ravenConfig.IGNORE_URLS, + shouldSendCallback: jasmine.any(Function), + }); + }); + }); + + describe('setUser', () => { + let ravenConfig; + + beforeEach(() => { + ravenConfig = { options: { currentUserId: 1 } }; + spyOn(Raven, 'setUserContext'); + + RavenConfig.setUser.call(ravenConfig); + }); + + it('should call .setUserContext', function () { + expect(Raven.setUserContext).toHaveBeenCalledWith({ + id: ravenConfig.options.currentUserId, + }); + }); + }); + + describe('bindRavenErrors', () => { + let $document; + let $; + + beforeEach(() => { + $document = jasmine.createSpyObj('$document', ['on']); + $ = jasmine.createSpy('$').and.returnValue($document); + + window.$ = $; + + RavenConfig.bindRavenErrors(); + }); + + it('should call .on', function () { + expect($document.on).toHaveBeenCalledWith('ajaxError.raven', RavenConfig.handleRavenErrors); + }); + }); + + describe('handleRavenErrors', () => { + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'responseText', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + spyOn(Raven, 'captureMessage'); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should call Raven.captureMessage', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: req.statusText, + event, + }, + }); + }); + }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + Raven.captureMessage.calls.reset(); + + RavenConfig.handleRavenErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Raven.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); + }); + + describe('shouldSendSample', () => { + let randomNumber; + + beforeEach(() => { + RavenConfig.SAMPLE_RATE = 50; + + spyOn(Math, 'random').and.callFake(() => randomNumber); + }); + + it('should call Math.random', () => { + RavenConfig.shouldSendSample(); + + expect(Math.random).toHaveBeenCalled(); + }); + + it('should return true if the sample rate is greater than the random number * 100', () => { + randomNumber = 0.1; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + + it('should return false if the sample rate is less than the random number * 100', () => { + randomNumber = 0.9; + + expect(RavenConfig.shouldSendSample()).toBe(false); + }); + + it('should return true if the sample rate is equal to the random number * 100', () => { + randomNumber = 0.5; + + expect(RavenConfig.shouldSendSample()).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js new file mode 100644 index 00000000000..5b5b1bf4140 --- /dev/null +++ b/spec/javascripts/sidebar/assignee_title_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title'; + +describe('AssigneeTitle component', () => { + let component; + let AssigneeTitleComponent; + + beforeEach(() => { + AssigneeTitleComponent = Vue.extend(AssigneeTitle); + }); + + describe('assignee title', () => { + it('renders assignee', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 1, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('Assignee'); + }); + + it('renders 2 assignees', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 2, + editable: false, + }, + }).$mount(); + + expect(component.$el.innerText.trim()).toEqual('2 Assignees'); + }); + }); + + it('does not render spinner by default', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).toBeNull(); + }); + + it('renders spinner when loading', () => { + component = new AssigneeTitleComponent({ + propsData: { + loading: true, + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.fa')).not.toBeNull(); + }); + + it('does not render edit link when not editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: false, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).toBeNull(); + }); + + it('renders edit link when editable', () => { + component = new AssigneeTitleComponent({ + propsData: { + numberOfAssignees: 0, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.edit-link')).not.toBeNull(); + }); +}); diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js new file mode 100644 index 00000000000..c9453a21189 --- /dev/null +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -0,0 +1,272 @@ +import Vue from 'vue'; +import Assignee from '~/sidebar/components/assignees/assignees'; +import UsersMock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Assignee component', () => { + let component; + let AssigneeComponent; + + beforeEach(() => { + AssigneeComponent = Vue.extend(Assignee); + }); + + describe('No assignees/users', () => { + it('displays no assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(1); + expect(collapsed.children[0].getAttribute('aria-label')).toEqual('No Assignee'); + expect(collapsed.children[0].classList.contains('fa')).toEqual(true); + expect(collapsed.children[0].classList.contains('fa-user')).toEqual(true); + }); + + it('displays only "No assignee" when no users are assigned and the issue is read-only', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: false, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers).toBe('No assignee'); + expect(componentTextNoUsers.indexOf('assign yourself')).toEqual(-1); + }); + + it('displays only "No assignee" when no users are assigned and the issue can be edited', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + const componentTextNoUsers = component.$el.querySelector('.assign-yourself').innerText.trim(); + + expect(componentTextNoUsers.indexOf('No assignee')).toEqual(0); + expect(componentTextNoUsers.indexOf('assign yourself')).toBeGreaterThan(0); + }); + + it('emits the assign-self event when "assign yourself" is clicked', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [], + editable: true, + }, + }).$mount(); + + spyOn(component, '$emit'); + component.$el.querySelector('.assign-yourself .btn-link').click(); + expect(component.$emit).toHaveBeenCalledWith('assign-self'); + }); + }); + + describe('One assignee/user', () => { + it('displays one assignee icon when collapsed', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users: [ + UsersMock.user, + ], + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + const assignee = collapsed.children[0]; + expect(collapsed.childElementCount).toEqual(1); + expect(assignee.querySelector('.avatar').getAttribute('src')).toEqual(UsersMock.user.avatar); + expect(assignee.querySelector('.avatar').getAttribute('alt')).toEqual(`${UsersMock.user.name}'s avatar`); + expect(assignee.querySelector('.author').innerText.trim()).toEqual(UsersMock.user.name); + }); + + it('Shows one user with avatar, username and author name', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelector('.author_link')).not.toBeNull(); + // The image + expect(component.$el.querySelector('.author_link img').getAttribute('src')).toEqual(UsersMock.user.avatar); + // Author name + expect(component.$el.querySelector('.author_link .author').innerText.trim()).toEqual(UsersMock.user.name); + // Username + expect(component.$el.querySelector('.author_link .username').innerText.trim()).toEqual(`@${UsersMock.user.username}`); + }); + + it('has the root url present in the assigneeUrl method', () => { + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000/', + users: [ + UsersMock.user, + ], + editable: true, + }, + }).$mount(); + + expect(component.assigneeUrl(UsersMock.user).indexOf('http://localhost:3000/')).not.toEqual(-1); + }); + }); + + describe('Two or more assignees/users', () => { + it('displays two assignee icons when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar').getAttribute('src')).toEqual(users[1].avatar); + expect(second.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[1].name}'s avatar`); + expect(second.querySelector('.author').innerText.trim()).toEqual(users[1].name); + }); + + it('displays one assignee icon and counter when collapsed', () => { + const users = UsersMockHelper.createNumberRandomUsers(3); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: false, + }, + }).$mount(); + + const collapsed = component.$el.querySelector('.sidebar-collapsed-icon'); + expect(collapsed.childElementCount).toEqual(2); + + const first = collapsed.children[0]; + expect(first.querySelector('.avatar').getAttribute('src')).toEqual(users[0].avatar); + expect(first.querySelector('.avatar').getAttribute('alt')).toEqual(`${users[0].name}'s avatar`); + expect(first.querySelector('.author').innerText.trim()).toEqual(users[0].name); + + const second = collapsed.children[1]; + expect(second.querySelector('.avatar-counter').innerText.trim()).toEqual('+2'); + }); + + it('Shows two assignees', () => { + const users = UsersMockHelper.createNumberRandomUsers(2); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(users.length); + expect(component.$el.querySelector('.user-list-more')).toBe(null); + }); + + it('Shows the "show-less" assignees label', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.$el.querySelectorAll('.user-item').length).toEqual(component.defaultRenderCount); + expect(component.$el.querySelector('.user-list-more')).not.toBe(null); + const usersLabelExpectation = users.length - component.defaultRenderCount; + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .not.toBe(`+${usersLabelExpectation} more`); + component.toggleShowLess(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('Shows the "show-less" when "n+ more " label is clicked', (done) => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + component.$el.querySelector('.user-list-more .btn-link').click(); + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + + it('gets the count of avatar via a computed property ', () => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + + expect(component.sidebarAvatarCounter).toEqual(`+${users.length - 1}`); + }); + + describe('n+ more label', () => { + beforeEach(() => { + const users = UsersMockHelper.createNumberRandomUsers(6); + component = new AssigneeComponent({ + propsData: { + rootPath: 'http://localhost:3000', + users, + editable: true, + }, + }).$mount(); + }); + + it('shows "+1 more" label', () => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('+ 1 more'); + }); + + it('shows "show less" label', (done) => { + component.toggleShowLess(); + + Vue.nextTick(() => { + expect(component.$el.querySelector('.user-list-more .btn-link').innerText.trim()) + .toBe('- show less'); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js new file mode 100644 index 00000000000..9fc8667ecc9 --- /dev/null +++ b/spec/javascripts/sidebar/mock_data.js @@ -0,0 +1,109 @@ +/* eslint-disable quote-props*/ + +const sidebarMockData = { + 'GET': { + '/gitlab-org/gitlab-shell/issues/5.json': { + id: 45, + iid: 5, + author_id: 23, + description: 'Nulla ullam commodi delectus adipisci quis sit.', + lock_version: null, + milestone_id: 21, + position: 0, + state: 'closed', + title: 'Vel et nulla voluptatibus corporis dolor iste saepe laborum.', + updated_by_id: 1, + created_at: '2017-02-02T21: 49: 49.664Z', + updated_at: '2017-05-03T22: 26: 03.760Z', + deleted_at: null, + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + branch_name: null, + confidential: false, + assignees: [ + { + name: 'User 0', + username: 'user0', + id: 22, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/user0', + }, + { + name: 'Marguerite Bartell', + username: 'tajuana', + id: 18, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/tajuana', + }, + { + name: 'Laureen Ritchie', + username: 'michaele.will', + id: 16, + state: 'active', + avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon', + web_url: 'http: //localhost:3001/michaele.will', + }, + ], + due_date: null, + moved_to_id: null, + project_id: 4, + weight: null, + milestone: { + id: 21, + iid: 1, + project_id: 4, + title: 'v0.0', + description: 'Molestiae commodi laboriosam odio sunt eaque reprehenderit.', + state: 'active', + created_at: '2017-02-02T21: 49: 30.530Z', + updated_at: '2017-02-02T21: 49: 30.530Z', + due_date: null, + start_date: null, + }, + labels: [], + }, + }, + 'PUT': { + '/gitlab-org/gitlab-shell/issues/5.json': { + data: {}, + }, + }, +}; + +export default { + mediator: { + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + editable: true, + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }, + rootPath: '/', + }, + time: { + time_estimate: 3600, + total_time_spent: 0, + human_time_estimate: '1h', + human_total_time_spent: null, + }, + user: { + avatar: 'http://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 1, + name: 'Administrator', + username: 'root', + }, + + sidebarMockInterceptor(request, next) { + const body = sidebarMockData[request.method.toUpperCase()][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200, + })); + }, +}; diff --git a/spec/javascripts/sidebar/sidebar_assignees_spec.js b/spec/javascripts/sidebar/sidebar_assignees_spec.js new file mode 100644 index 00000000000..e0df0a3228f --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_assignees_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar assignees', () => { + let component; + let SidebarAssigneeComponent; + preloadFixtures('issues/open-issue.html.raw'); + + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + SidebarAssigneeComponent = Vue.extend(SidebarAssignees); + spyOn(SidebarMediator.prototype, 'saveAssignees').and.callThrough(); + spyOn(SidebarMediator.prototype, 'assignYourself').and.callThrough(); + this.mediator = new SidebarMediator(Mock.mediator); + loadFixtures('issues/open-issue.html.raw'); + this.sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('calls the mediator when saves the assignees', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.saveAssignees(); + + expect(SidebarMediator.prototype.saveAssignees).toHaveBeenCalled(); + }); + + it('calls the mediator when "assignSelf" method is called', () => { + component = new SidebarAssigneeComponent() + .$mount(this.sidebarAssigneesEl); + component.assignSelf(); + + expect(SidebarMediator.prototype.assignYourself).toHaveBeenCalled(); + expect(this.mediator.store.assignees.length).toEqual(1); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_bundle_spec.js b/spec/javascripts/sidebar/sidebar_bundle_spec.js new file mode 100644 index 00000000000..7760b34e071 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_bundle_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import SidebarBundleDomContentLoaded from '~/sidebar/sidebar_bundle'; +import SidebarTimeTracking from '~/sidebar/components/time_tracking/sidebar_time_tracking'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; + +describe('sidebar bundle', () => { + gl.sidebarOptions = Mock.mediator; + + beforeEach(() => { + spyOn(SidebarTimeTracking.methods, 'listenForSlashCommands').and.callFake(() => { }); + preloadFixtures('issues/open-issue.html.raw'); + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + loadFixtures('issues/open-issue.html.raw'); + spyOn(Vue.prototype, '$mount'); + SidebarBundleDomContentLoaded(); + this.mediator = new SidebarMediator(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('the mediator should be already defined with some data', () => { + SidebarBundleDomContentLoaded(); + + expect(this.mediator.store).toBeDefined(); + expect(this.mediator.service).toBeDefined(); + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.rootPath).toEqual(Mock.mediator.rootPath); + expect(this.mediator.store.endPoint).toEqual(Mock.mediator.endPoint); + expect(this.mediator.store.editable).toEqual(Mock.mediator.editable); + }); + + it('the sidebar time tracking and assignees components to have been mounted', () => { + expect(Vue.prototype.$mount).toHaveBeenCalledTimes(2); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js new file mode 100644 index 00000000000..2b00fa17334 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar mediator', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.mediator = new SidebarMediator(Mock.mediator); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + }); + + it('assigns yourself ', () => { + this.mediator.assignYourself(); + + expect(this.mediator.store.currentUser).toEqual(Mock.mediator.currentUser); + expect(this.mediator.store.assignees[0]).toEqual(Mock.mediator.currentUser); + }); + + it('saves assignees', (done) => { + this.mediator.saveAssignees('issue[assignee_ids]') + .then((resp) => { + expect(resp.status).toEqual(200); + done(); + }) + .catch(() => {}); + }); + + it('fetches the data', () => { + spyOn(this.mediator.service, 'get').and.callThrough(); + this.mediator.fetch(); + expect(this.mediator.service.get).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js new file mode 100644 index 00000000000..d41162096a6 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import Mock from './mock_data'; + +describe('Sidebar service', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json'); + }); + + afterEach(() => { + SidebarService.singleton = null; + }); + + it('gets the data', (done) => { + this.service.get() + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); + }); + + it('updates the data', (done) => { + this.service.update('issue[assignee_ids]', [1]) + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(() => {}); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js new file mode 100644 index 00000000000..29facf483b5 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -0,0 +1,80 @@ +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from './mock_data'; +import UsersMockHelper from '../helpers/user_mock_data_helper'; + +describe('Sidebar store', () => { + const assignee = { + id: 2, + name: 'gitlab user 2', + username: 'gitlab2', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + const anotherAssignee = { + id: 3, + name: 'gitlab user 3', + username: 'gitlab3', + avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + }; + + beforeEach(() => { + this.store = new SidebarStore({ + currentUser: { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'http://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('adds a new assignee', () => { + this.store.addAssignee(assignee); + expect(this.store.assignees.length).toEqual(1); + }); + + it('removes an assignee', () => { + this.store.removeAssignee(assignee); + expect(this.store.assignees.length).toEqual(0); + }); + + it('finds an existent assignee', () => { + let foundAssignee; + + this.store.addAssignee(assignee); + foundAssignee = this.store.findAssignee(assignee); + expect(foundAssignee).toBeDefined(); + expect(foundAssignee).toEqual(assignee); + foundAssignee = this.store.findAssignee(anotherAssignee); + expect(foundAssignee).toBeUndefined(); + }); + + it('removes all assignees', () => { + this.store.removeAllAssignees(); + expect(this.store.assignees.length).toEqual(0); + }); + + it('set assigned data', () => { + const users = { + assignees: UsersMockHelper.createNumberRandomUsers(3), + }; + + this.store.setAssigneeData(users); + expect(this.store.assignees.length).toEqual(3); + }); + + it('set time tracking data', () => { + this.store.setTimeTrackingData(Mock.time); + expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate); + expect(this.store.totalTimeSpent).toEqual(Mock.time.total_time_spent); + expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); + expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); + }); +}); diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js b/spec/javascripts/signin_tabs_memoizer_spec.js index d83d9a57b42..5b4f5933b34 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js +++ b/spec/javascripts/signin_tabs_memoizer_spec.js @@ -1,3 +1,5 @@ +import AccessorUtilities from '~/lib/utils/accessor'; + require('~/signin_tabs_memoizer'); ((global) => { @@ -19,6 +21,8 @@ require('~/signin_tabs_memoizer'); beforeEach(() => { loadFixtures(fixtureTemplate); + + spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); }); it('does nothing if no tab was previously selected', () => { @@ -49,5 +53,91 @@ require('~/signin_tabs_memoizer'); expect(memo.readData()).toEqual('#standard'); }); + + describe('class constructor', () => { + beforeEach(() => { + memo = createMemoizer(); + }); + + it('should set .isLocalStorageAvailable', () => { + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); + expect(memo.isLocalStorageAvailable).toBe(true); + }); + }); + + describe('saveData', () => { + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'setItem'); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + global.ActiveTabMemoizer.prototype.saveData.call(memo); + }); + + it('should not call .setItem', () => { + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + const value = 'value'; + + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + global.ActiveTabMemoizer.prototype.saveData.call(memo, value); + }); + + it('should call .setItem', () => { + expect(localStorage.setItem).toHaveBeenCalledWith(currentTabKey, value); + }); + }); + }); + + describe('readData', () => { + const itemValue = 'itemValue'; + let readData; + + beforeEach(() => { + memo = { + currentTabKey, + }; + + spyOn(localStorage, 'getItem').and.returnValue(itemValue); + }); + + describe('if .isLocalStorageAvailable is `false`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = false; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should not call .getItem and should return `null`', () => { + expect(localStorage.getItem).not.toHaveBeenCalled(); + expect(readData).toBe(null); + }); + }); + + describe('if .isLocalStorageAvailable is `true`', () => { + beforeEach(function () { + memo.isLocalStorageAvailable = true; + + readData = global.ActiveTabMemoizer.prototype.readData.call(memo); + }); + + it('should call .getItem and return the localStorage value', () => { + expect(window.localStorage.getItem).toHaveBeenCalledWith(currentTabKey); + expect(readData).toBe(itemValue); + }); + }); + }); }); })(window); diff --git a/spec/javascripts/subbable_resource_spec.js b/spec/javascripts/subbable_resource_spec.js deleted file mode 100644 index 454386697f5..00000000000 --- a/spec/javascripts/subbable_resource_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-disable max-len, arrow-parens, comma-dangle */ - -require('~/subbable_resource'); - -/* -* Test that each rest verb calls the publish and subscribe function and passes the correct value back -* -* -* */ -((global) => { - describe('Subbable Resource', function () { - describe('PubSub', function () { - beforeEach(function () { - this.MockResource = new global.SubbableResource('https://example.com'); - }); - it('should successfully add a single subscriber', function () { - const callback = () => {}; - this.MockResource.subscribe(callback); - - expect(this.MockResource.subscribers.length).toBe(1); - expect(this.MockResource.subscribers[0]).toBe(callback); - }); - - it('should successfully add multiple subscribers', function () { - const callbackOne = () => {}; - const callbackTwo = () => {}; - const callbackThree = () => {}; - - this.MockResource.subscribe(callbackOne); - this.MockResource.subscribe(callbackTwo); - this.MockResource.subscribe(callbackThree); - - expect(this.MockResource.subscribers.length).toBe(3); - }); - - it('should successfully publish an update to a single subscriber', function () { - const state = { myprop: 1 }; - - const callbacks = { - one: (data) => expect(data.myprop).toBe(2), - two: (data) => expect(data.myprop).toBe(2), - three: (data) => expect(data.myprop).toBe(2) - }; - - const spyOne = spyOn(callbacks, 'one'); - const spyTwo = spyOn(callbacks, 'two'); - const spyThree = spyOn(callbacks, 'three'); - - this.MockResource.subscribe(callbacks.one); - this.MockResource.subscribe(callbacks.two); - this.MockResource.subscribe(callbacks.three); - - state.myprop += 1; - - this.MockResource.publish(state); - - expect(spyOne).toHaveBeenCalled(); - expect(spyTwo).toHaveBeenCalled(); - expect(spyThree).toHaveBeenCalled(); - }); - }); - }); -})(window.gl || (window.gl = {})); |