diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-11 12:09:45 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-11 12:09:45 +0300 |
commit | c7ba7b997608a103a0a9165b2e5cef9530c4ef53 (patch) | |
tree | 3af88eaacba25539b97da4ad358418b6a69c11d7 /spec/frontend | |
parent | a031b1f4f34470fba578856dc7ab735a6f54733a (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend')
40 files changed, 3218 insertions, 22 deletions
diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js index cb2531663bd..d7170e71a96 100644 --- a/spec/frontend/alert_management/components/alert_management_list_spec.js +++ b/spec/frontend/alert_management/components/alert_management_list_spec.js @@ -9,6 +9,7 @@ import { GlIcon, GlTab, } from '@gitlab/ui'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import AlertManagementList from '~/alert_management/components/alert_management_list.vue'; import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants'; @@ -24,6 +25,7 @@ describe('AlertManagementList', () => { const findStatusDropdown = () => wrapper.find(GlNewDropdown); const findStatusFilterTabs = () => wrapper.findAll(GlTab); const findNumberOfAlertsBadge = () => wrapper.findAll(GlBadge); + const findDateFields = () => wrapper.findAll(TimeAgo); function mountComponent({ props = { @@ -198,5 +200,45 @@ describe('AlertManagementList', () => { ).toBe(true); }); }); + + describe('handle date fields', () => { + it('should display time ago dates when values provided', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { + alerts: [ + { + iid: 1, + startedAt: '2020-03-17T23:18:14.996Z', + endedAt: '2020-04-17T23:18:14.996Z', + severity: 'high', + }, + ], + errored: false, + }, + loading: false, + }); + expect(findDateFields().length).toBe(2); + }); + + it('should not display time ago dates when values not provided', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { + alerts: [ + { + iid: 1, + startedAt: null, + endedAt: null, + severity: 'high', + }, + ], + errored: false, + }, + loading: false, + }); + expect(findDateFields().exists()).toBe(false); + }); + }); }); }); diff --git a/spec/frontend/ci_variable_list/services/mock_data.js b/spec/frontend/ci_variable_list/services/mock_data.js index 09c6cd9de21..7dab33050d9 100644 --- a/spec/frontend/ci_variable_list/services/mock_data.js +++ b/spec/frontend/ci_variable_list/services/mock_data.js @@ -8,7 +8,7 @@ export default { protected: false, secret_value: 'test_val', value: 'test_val', - variable_type: 'Var', + variable_type: 'Variable', }, ], @@ -44,7 +44,7 @@ export default { protected: false, secret_value: 'test_val', value: 'test_val', - variable_type: 'Var', + variable_type: 'Variable', }, { environment_scope: 'All (default)', @@ -104,7 +104,7 @@ export default { id: 28, key: 'goku_var', value: 'goku_val', - variable_type: 'Var', + variable_type: 'Variable', protected: true, masked: true, environment_scope: 'staging', @@ -114,7 +114,7 @@ export default { id: 25, key: 'test_var_4', value: 'test_val_4', - variable_type: 'Var', + variable_type: 'Variable', protected: false, masked: false, environment_scope: 'production', @@ -134,7 +134,7 @@ export default { id: 24, key: 'test_var_3', value: 'test_val_3', - variable_type: 'Var', + variable_type: 'Variable', protected: false, masked: false, environment_scope: 'All (default)', @@ -144,7 +144,7 @@ export default { id: 26, key: 'test_var_5', value: 'test_val_5', - variable_type: 'Var', + variable_type: 'Variable', protected: false, masked: false, environment_scope: 'production', diff --git a/spec/frontend/ci_variable_list/store/mutations_spec.js b/spec/frontend/ci_variable_list/store/mutations_spec.js index 8652359f3df..ce0792d0353 100644 --- a/spec/frontend/ci_variable_list/store/mutations_spec.js +++ b/spec/frontend/ci_variable_list/store/mutations_spec.js @@ -47,7 +47,7 @@ describe('CI variable list mutations', () => { describe('CLEAR_MODAL', () => { it('should clear modal state ', () => { const modalState = { - variable_type: 'Var', + variable_type: 'Variable', key: '', secret_value: '', protected: false, diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 658ad37d7f2..f4d4122bd5a 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -221,4 +221,37 @@ describe('IDE services', () => { }); }); }); + + describe('getFiles', () => { + let mock; + let relativeUrlRoot; + const TEST_RELATIVE_URL_ROOT = 'blah-blah'; + + beforeEach(() => { + jest.spyOn(axios, 'get'); + relativeUrlRoot = gon.relative_url_root; + gon.relative_url_root = TEST_RELATIVE_URL_ROOT; + + mock = new MockAdapter(axios); + + mock + .onGet(`${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`) + .reply(200, [TEST_FILE_PATH]); + }); + + afterEach(() => { + mock.restore(); + gon.relative_url_root = relativeUrlRoot; + }); + + it('initates the api call based on the passed path and commit hash', () => { + return services.getFiles(TEST_PROJECT_ID, TEST_COMMIT_SHA).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith( + `${gon.relative_url_root}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`, + expect.any(Object), + ); + expect(data).toEqual([TEST_FILE_PATH]); + }); + }); + }); }); diff --git a/spec/frontend/image_diff/helpers/badge_helper_spec.js b/spec/frontend/image_diff/helpers/badge_helper_spec.js new file mode 100644 index 00000000000..c970ccc535d --- /dev/null +++ b/spec/frontend/image_diff/helpers/badge_helper_spec.js @@ -0,0 +1,130 @@ +import * as badgeHelper from '~/image_diff/helpers/badge_helper'; +import * as mockData from '../mock_data'; + +describe('badge helper', () => { + const { coordinate, noteId, badgeText, badgeNumber } = mockData; + let containerEl; + let buttonEl; + + beforeEach(() => { + containerEl = document.createElement('div'); + }); + + describe('createImageBadge', () => { + beforeEach(() => { + buttonEl = badgeHelper.createImageBadge(noteId, coordinate); + }); + + it('should create button', () => { + expect(buttonEl.tagName).toEqual('BUTTON'); + expect(buttonEl.getAttribute('type')).toEqual('button'); + }); + + it('should set disabled attribute', () => { + expect(buttonEl.hasAttribute('disabled')).toEqual(true); + }); + + it('should set noteId', () => { + expect(buttonEl.dataset.noteId).toEqual(noteId); + }); + + it('should set coordinate', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + describe('classNames', () => { + it('should set .js-image-badge by default', () => { + expect(buttonEl.className).toEqual('js-image-badge'); + }); + + it('should add additional class names if parameter is passed', () => { + const classNames = ['first-class', 'second-class']; + buttonEl = badgeHelper.createImageBadge(noteId, coordinate, classNames); + + expect(buttonEl.className).toEqual(classNames.concat('js-image-badge').join(' ')); + }); + }); + }); + + describe('addImageBadge', () => { + beforeEach(() => { + badgeHelper.addImageBadge(containerEl, { + coordinate, + badgeText, + noteId, + }); + buttonEl = containerEl.querySelector('button'); + }); + + it('should appends button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + it('should add badge classes', () => { + expect(buttonEl.className).toContain('badge badge-pill'); + }); + + it('should set the badge text', () => { + expect(buttonEl.textContent).toEqual(badgeText); + }); + + it('should set the button coordinates', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + it('should set the button noteId', () => { + expect(buttonEl.dataset.noteId).toEqual(noteId); + }); + }); + + describe('addImageCommentBadge', () => { + beforeEach(() => { + badgeHelper.addImageCommentBadge(containerEl, { + coordinate, + noteId, + }); + buttonEl = containerEl.querySelector('button'); + }); + + it('should append icon button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + it('should create icon comment button', () => { + const iconEl = buttonEl.querySelector('svg'); + + expect(iconEl).toBeDefined(); + }); + }); + + describe('addAvatarBadge', () => { + let avatarBadgeEl; + + beforeEach(() => { + containerEl.innerHTML = ` + <div id="${noteId}"> + <div class="badge hidden"> + </div> + </div> + `; + + badgeHelper.addAvatarBadge(containerEl, { + detail: { + noteId, + badgeNumber, + }, + }); + avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`); + }); + + it('should update badge number', () => { + expect(avatarBadgeEl.textContent).toEqual(badgeNumber.toString()); + }); + + it('should remove hidden class', () => { + expect(avatarBadgeEl.classList.contains('hidden')).toEqual(false); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js new file mode 100644 index 00000000000..395bb7de362 --- /dev/null +++ b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js @@ -0,0 +1,144 @@ +import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper'; +import * as mockData from '../mock_data'; + +describe('commentIndicatorHelper', () => { + const { coordinate } = mockData; + let containerEl; + + beforeEach(() => { + containerEl = document.createElement('div'); + }); + + describe('addCommentIndicator', () => { + let buttonEl; + + beforeEach(() => { + commentIndicatorHelper.addCommentIndicator(containerEl, coordinate); + buttonEl = containerEl.querySelector('button'); + }); + + it('should append button to container', () => { + expect(buttonEl).toBeDefined(); + }); + + describe('button', () => { + it('should set coordinate', () => { + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + + it('should contain image-comment-dark svg', () => { + const svgEl = buttonEl.querySelector('svg'); + + expect(svgEl).toBeDefined(); + + const svgLink = svgEl.querySelector('use').getAttribute('xlink:href'); + + expect(svgLink.indexOf('image-comment-dark')).not.toBe(-1); + }); + }); + }); + + describe('removeCommentIndicator', () => { + it('should return removed false if there is no comment-indicator', () => { + const result = commentIndicatorHelper.removeCommentIndicator(containerEl); + + expect(result.removed).toEqual(false); + }); + + describe('has comment indicator', () => { + let result; + + beforeEach(() => { + containerEl.innerHTML = ` + <div class="comment-indicator" style="left:${coordinate.x}px; top: ${coordinate.y}px;"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + `; + result = commentIndicatorHelper.removeCommentIndicator(containerEl); + }); + + it('should remove comment indicator', () => { + expect(containerEl.querySelector('.comment-indicator')).toBeNull(); + }); + + it('should return removed true', () => { + expect(result.removed).toEqual(true); + }); + + it('should return indicator meta', () => { + expect(result.x).toEqual(coordinate.x); + expect(result.y).toEqual(coordinate.y); + expect(result.image).toBeDefined(); + expect(result.image.width).toBeDefined(); + expect(result.image.height).toBeDefined(); + }); + }); + }); + + describe('showCommentIndicator', () => { + describe('commentIndicator exists', () => { + beforeEach(() => { + containerEl.innerHTML = ` + <button class="comment-indicator"></button> + `; + commentIndicatorHelper.showCommentIndicator(containerEl, coordinate); + }); + + it('should set commentIndicator coordinates', () => { + const commentIndicatorEl = containerEl.querySelector('.comment-indicator'); + + expect(commentIndicatorEl.style.left).toEqual(`${coordinate.x}px`); + expect(commentIndicatorEl.style.top).toEqual(`${coordinate.y}px`); + }); + }); + + describe('commentIndicator does not exist', () => { + beforeEach(() => { + commentIndicatorHelper.showCommentIndicator(containerEl, coordinate); + }); + + it('should addCommentIndicator', () => { + const buttonEl = containerEl.querySelector('.comment-indicator'); + + expect(buttonEl).toBeDefined(); + expect(buttonEl.style.left).toEqual(`${coordinate.x}px`); + expect(buttonEl.style.top).toEqual(`${coordinate.y}px`); + }); + }); + }); + + describe('commentIndicatorOnClick', () => { + let event; + let textAreaEl; + + beforeEach(() => { + containerEl.innerHTML = ` + <div class="diff-viewer"> + <button></button> + <div class="note-container"> + <textarea class="note-textarea"></textarea> + </div> + </div> + `; + textAreaEl = containerEl.querySelector('textarea'); + + event = { + stopPropagation: () => {}, + currentTarget: containerEl.querySelector('button'), + }; + + jest.spyOn(event, 'stopPropagation').mockImplementation(() => {}); + jest.spyOn(textAreaEl, 'focus').mockImplementation(() => {}); + commentIndicatorHelper.commentIndicatorOnClick(event); + }); + + it('should stopPropagation', () => { + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should focus textAreaEl', () => { + expect(textAreaEl.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/dom_helper_spec.js b/spec/frontend/image_diff/helpers/dom_helper_spec.js new file mode 100644 index 00000000000..9357d626bbe --- /dev/null +++ b/spec/frontend/image_diff/helpers/dom_helper_spec.js @@ -0,0 +1,120 @@ +import * as domHelper from '~/image_diff/helpers/dom_helper'; +import * as mockData from '../mock_data'; + +describe('domHelper', () => { + const { imageMeta, badgeNumber } = mockData; + + describe('setPositionDataAttribute', () => { + let containerEl; + let attributeAfterCall; + const position = { + myProperty: 'myProperty', + }; + + beforeEach(() => { + containerEl = document.createElement('div'); + containerEl.dataset.position = JSON.stringify(position); + domHelper.setPositionDataAttribute(containerEl, imageMeta); + attributeAfterCall = JSON.parse(containerEl.dataset.position); + }); + + it('should set x, y, width, height', () => { + expect(attributeAfterCall.x).toEqual(imageMeta.x); + expect(attributeAfterCall.y).toEqual(imageMeta.y); + expect(attributeAfterCall.width).toEqual(imageMeta.width); + expect(attributeAfterCall.height).toEqual(imageMeta.height); + }); + + it('should not override other properties', () => { + expect(attributeAfterCall.myProperty).toEqual('myProperty'); + }); + }); + + describe('updateDiscussionAvatarBadgeNumber', () => { + let discussionEl; + + beforeEach(() => { + discussionEl = document.createElement('div'); + discussionEl.innerHTML = ` + <a href="#" class="image-diff-avatar-link"> + <div class="badge"></div> + </a> + `; + domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber); + }); + + it('should update avatar badge number', () => { + expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString()); + }); + }); + + describe('updateDiscussionBadgeNumber', () => { + let discussionEl; + + beforeEach(() => { + discussionEl = document.createElement('div'); + discussionEl.innerHTML = ` + <div class="badge"></div> + `; + domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber); + }); + + it('should update discussion badge number', () => { + expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString()); + }); + }); + + describe('toggleCollapsed', () => { + let element; + let discussionNotesEl; + + beforeEach(() => { + element = document.createElement('div'); + element.innerHTML = ` + <div class="discussion-notes"> + <button></button> + <form class="discussion-form"></form> + </div> + `; + discussionNotesEl = element.querySelector('.discussion-notes'); + }); + + describe('not collapsed', () => { + beforeEach(() => { + domHelper.toggleCollapsed({ + currentTarget: element.querySelector('button'), + }); + }); + + it('should add collapsed class', () => { + expect(discussionNotesEl.classList.contains('collapsed')).toEqual(true); + }); + + it('should force formEl to display none', () => { + const formEl = element.querySelector('.discussion-form'); + + expect(formEl.style.display).toEqual('none'); + }); + }); + + describe('collapsed', () => { + beforeEach(() => { + discussionNotesEl.classList.add('collapsed'); + + domHelper.toggleCollapsed({ + currentTarget: element.querySelector('button'), + }); + }); + + it('should remove collapsed class', () => { + expect(discussionNotesEl.classList.contains('collapsed')).toEqual(false); + }); + + it('should force formEl to display block', () => { + const formEl = element.querySelector('.discussion-form'); + + expect(formEl.style.display).toEqual('block'); + }); + }); + }); +}); diff --git a/spec/frontend/image_diff/helpers/utils_helper_spec.js b/spec/frontend/image_diff/helpers/utils_helper_spec.js new file mode 100644 index 00000000000..3b6378be883 --- /dev/null +++ b/spec/frontend/image_diff/helpers/utils_helper_spec.js @@ -0,0 +1,152 @@ +import * as utilsHelper from '~/image_diff/helpers/utils_helper'; +import ImageBadge from '~/image_diff/image_badge'; +import * as mockData from '../mock_data'; + +describe('utilsHelper', () => { + const { noteId, discussionId, image, imageProperties, imageMeta } = mockData; + + describe('resizeCoordinatesToImageElement', () => { + let result; + + beforeEach(() => { + result = utilsHelper.resizeCoordinatesToImageElement(image, imageMeta); + }); + + it('should return x based on widthRatio', () => { + expect(result.x).toEqual(imageMeta.x * 0.5); + }); + + it('should return y based on heightRatio', () => { + expect(result.y).toEqual(imageMeta.y * 0.5); + }); + + it('should return image width', () => { + expect(result.width).toEqual(image.width); + }); + + it('should return image height', () => { + expect(result.height).toEqual(image.height); + }); + }); + + describe('generateBadgeFromDiscussionDOM', () => { + let discussionEl; + let result; + + beforeEach(() => { + const imageFrameEl = document.createElement('div'); + imageFrameEl.innerHTML = ` + <img src="${gl.TEST_HOST}/image.png"> + `; + discussionEl = document.createElement('div'); + discussionEl.dataset.discussionId = discussionId; + discussionEl.innerHTML = ` + <div class="note" id="${noteId}"></div> + `; + discussionEl.dataset.position = JSON.stringify(imageMeta); + result = utilsHelper.generateBadgeFromDiscussionDOM(imageFrameEl, discussionEl); + }); + + it('should return actual image properties', () => { + const { actual } = result; + + expect(actual.x).toEqual(imageMeta.x); + expect(actual.y).toEqual(imageMeta.y); + expect(actual.width).toEqual(imageMeta.width); + expect(actual.height).toEqual(imageMeta.height); + }); + + it('should return browser image properties', () => { + const { browser } = result; + + expect(browser.x).toBeDefined(); + expect(browser.y).toBeDefined(); + expect(browser.width).toBeDefined(); + expect(browser.height).toBeDefined(); + }); + + it('should return instance of ImageBadge', () => { + expect(result instanceof ImageBadge).toEqual(true); + }); + + it('should return noteId', () => { + expect(result.noteId).toEqual(noteId); + }); + + it('should return discussionId', () => { + expect(result.discussionId).toEqual(discussionId); + }); + }); + + describe('getTargetSelection', () => { + let containerEl; + + beforeEach(() => { + containerEl = { + querySelector: () => imageProperties, + }; + }); + + function generateEvent(offsetX, offsetY) { + return { + currentTarget: containerEl, + offsetX, + offsetY, + }; + } + + it('should return browser properties', () => { + const event = generateEvent(25, 25); + const result = utilsHelper.getTargetSelection(event); + + const { browser } = result; + + expect(browser.x).toEqual(event.offsetX); + expect(browser.y).toEqual(event.offsetY); + expect(browser.width).toEqual(imageProperties.width); + expect(browser.height).toEqual(imageProperties.height); + }); + + it('should return resized actual image properties', () => { + const event = generateEvent(50, 50); + const result = utilsHelper.getTargetSelection(event); + + const { actual } = result; + + expect(actual.x).toEqual(100); + expect(actual.y).toEqual(100); + expect(actual.width).toEqual(imageProperties.naturalWidth); + expect(actual.height).toEqual(imageProperties.naturalHeight); + }); + + describe('normalize coordinates', () => { + it('should return x = 0 if x < 0', () => { + const event = generateEvent(-5, 50); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.x).toEqual(0); + }); + + it('should return x = width if x > width', () => { + const event = generateEvent(1000, 50); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.x).toEqual(imageProperties.width); + }); + + it('should return y = 0 if y < 0', () => { + const event = generateEvent(50, -10); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.y).toEqual(0); + }); + + it('should return y = height if y > height', () => { + const event = generateEvent(50, 1000); + const result = utilsHelper.getTargetSelection(event); + + expect(result.browser.y).toEqual(imageProperties.height); + }); + }); + }); +}); diff --git a/spec/frontend/image_diff/image_badge_spec.js b/spec/frontend/image_diff/image_badge_spec.js new file mode 100644 index 00000000000..a11b50ead47 --- /dev/null +++ b/spec/frontend/image_diff/image_badge_spec.js @@ -0,0 +1,84 @@ +import ImageBadge from '~/image_diff/image_badge'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import * as mockData from './mock_data'; + +describe('ImageBadge', () => { + const { noteId, discussionId, imageMeta } = mockData; + const options = { + noteId, + discussionId, + }; + + it('should save actual property', () => { + const imageBadge = new ImageBadge({ ...options, actual: imageMeta }); + + const { actual } = imageBadge; + + expect(actual.x).toEqual(imageMeta.x); + expect(actual.y).toEqual(imageMeta.y); + expect(actual.width).toEqual(imageMeta.width); + expect(actual.height).toEqual(imageMeta.height); + }); + + it('should save browser property', () => { + const imageBadge = new ImageBadge({ ...options, browser: imageMeta }); + + const { browser } = imageBadge; + + expect(browser.x).toEqual(imageMeta.x); + expect(browser.y).toEqual(imageMeta.y); + expect(browser.width).toEqual(imageMeta.width); + expect(browser.height).toEqual(imageMeta.height); + }); + + it('should save noteId', () => { + const imageBadge = new ImageBadge(options); + + expect(imageBadge.noteId).toEqual(noteId); + }); + + it('should save discussionId', () => { + const imageBadge = new ImageBadge(options); + + expect(imageBadge.discussionId).toEqual(discussionId); + }); + + describe('default values', () => { + let imageBadge; + + beforeEach(() => { + imageBadge = new ImageBadge(options); + }); + + it('should return defaultimageMeta if actual property is not provided', () => { + const { actual } = imageBadge; + + expect(actual.x).toEqual(0); + expect(actual.y).toEqual(0); + expect(actual.width).toEqual(0); + expect(actual.height).toEqual(0); + }); + + it('should return defaultimageMeta if browser property is not provided', () => { + const { browser } = imageBadge; + + expect(browser.x).toEqual(0); + expect(browser.y).toEqual(0); + expect(browser.width).toEqual(0); + expect(browser.height).toEqual(0); + }); + }); + + describe('imageEl property is provided and not browser property', () => { + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(true); + }); + + it('should generate browser property', () => { + const imageBadge = new ImageBadge({ ...options, imageEl: document.createElement('img') }); + + expect(imageDiffHelper.resizeCoordinatesToImageElement).toHaveBeenCalled(); + expect(imageBadge.browser).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js new file mode 100644 index 00000000000..c15718b5106 --- /dev/null +++ b/spec/frontend/image_diff/image_diff_spec.js @@ -0,0 +1,361 @@ +import ImageDiff from '~/image_diff/image_diff'; +import * as imageUtility from '~/lib/utils/image_utility'; +import imageDiffHelper from '~/image_diff/helpers/index'; +import * as mockData from './mock_data'; + +describe('ImageDiff', () => { + let element; + let imageDiff; + + beforeEach(() => { + setFixtures(` + <div id="element"> + <div class="diff-file"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + <div class="comment-indicator"></div> + <div id="badge-1" class="badge">1</div> + <div id="badge-2" class="badge">2</div> + <div id="badge-3" class="badge">3</div> + </div> + <div class="note-container"> + <div class="discussion-notes"> + <div class="js-diff-notes-toggle"></div> + <div class="notes"></div> + </div> + <div class="discussion-notes"> + <div class="js-diff-notes-toggle"></div> + <div class="notes"></div> + </div> + </div> + </div> + </div> + `); + element = document.getElementById('element'); + }); + + describe('constructor', () => { + beforeEach(() => { + imageDiff = new ImageDiff(element, { + canCreateNote: true, + renderCommentBadge: true, + }); + }); + + it('should set el', () => { + expect(imageDiff.el).toEqual(element); + }); + + it('should set canCreateNote', () => { + expect(imageDiff.canCreateNote).toEqual(true); + }); + + it('should set renderCommentBadge', () => { + expect(imageDiff.renderCommentBadge).toEqual(true); + }); + + it('should set $noteContainer', () => { + expect(imageDiff.$noteContainer[0]).toEqual(element.querySelector('.note-container')); + }); + + describe('default', () => { + beforeEach(() => { + imageDiff = new ImageDiff(element); + }); + + it('should set canCreateNote as false', () => { + expect(imageDiff.canCreateNote).toEqual(false); + }); + + it('should set renderCommentBadge as false', () => { + expect(imageDiff.renderCommentBadge).toEqual(false); + }); + }); + }); + + describe('init', () => { + beforeEach(() => { + jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.init(); + }); + + it('should set imageFrameEl', () => { + expect(imageDiff.imageFrameEl).toEqual(element.querySelector('.diff-file .js-image-frame')); + }); + + it('should set imageEl', () => { + expect(imageDiff.imageEl).toEqual(element.querySelector('.diff-file .js-image-frame img')); + }); + + it('should call bindEvents', () => { + expect(imageDiff.bindEvents).toHaveBeenCalled(); + }); + }); + + describe('bindEvents', () => { + let imageEl; + + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'toggleCollapsed').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'commentIndicatorOnClick').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'imageClicked').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'addBadge').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'removeBadge').mockImplementation(() => {}); + jest.spyOn(ImageDiff.prototype, 'renderBadges').mockImplementation(() => {}); + imageEl = element.querySelector('.diff-file .js-image-frame img'); + }); + + describe('default', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should register click event delegation to js-diff-notes-toggle', () => { + element.querySelector('.js-diff-notes-toggle').click(); + + expect(imageDiffHelper.toggleCollapsed).toHaveBeenCalled(); + }); + + it('should register click event delegation to comment-indicator', () => { + element.querySelector('.comment-indicator').click(); + + expect(imageDiffHelper.commentIndicatorOnClick).toHaveBeenCalled(); + }); + }); + + describe('image not loaded', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should registers load eventListener', () => { + const loadEvent = new Event('load'); + imageEl.dispatchEvent(loadEvent); + + expect(imageDiff.renderBadges).toHaveBeenCalled(); + }); + }); + + describe('canCreateNote', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element, { + canCreateNote: true, + }); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should register click.imageDiff event', () => { + const event = new CustomEvent('click.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.imageClicked).toHaveBeenCalled(); + }); + + it('should register blur.imageDiff event', () => { + const event = new CustomEvent('blur.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled(); + }); + + it('should register addBadge.imageDiff event', () => { + const event = new CustomEvent('addBadge.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.addBadge).toHaveBeenCalled(); + }); + + it('should register removeBadge.imageDiff event', () => { + const event = new CustomEvent('removeBadge.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.removeBadge).toHaveBeenCalled(); + }); + }); + + describe('canCreateNote is false', () => { + beforeEach(() => { + jest.spyOn(imageUtility, 'isImageLoaded').mockReturnValue(false); + imageDiff = new ImageDiff(element); + imageDiff.imageEl = imageEl; + imageDiff.bindEvents(); + }); + + it('should not register click.imageDiff event', () => { + const event = new CustomEvent('click.imageDiff'); + element.dispatchEvent(event); + + expect(imageDiff.imageClicked).not.toHaveBeenCalled(); + }); + }); + }); + + describe('imageClicked', () => { + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'getTargetSelection').mockReturnValue({ + actual: {}, + browser: {}, + }); + jest.spyOn(imageDiffHelper, 'setPositionDataAttribute').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageClicked({ + detail: { + currentTarget: {}, + }, + }); + }); + + it('should call getTargetSelection', () => { + expect(imageDiffHelper.getTargetSelection).toHaveBeenCalled(); + }); + + it('should call setPositionDataAttribute', () => { + expect(imageDiffHelper.setPositionDataAttribute).toHaveBeenCalled(); + }); + + it('should call showCommentIndicator', () => { + expect(imageDiffHelper.showCommentIndicator).toHaveBeenCalled(); + }); + }); + + describe('renderBadges', () => { + beforeEach(() => { + jest.spyOn(ImageDiff.prototype, 'renderBadge').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.renderBadges(); + }); + + it('should call renderBadge for each discussionEl', () => { + const discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes'); + + expect(imageDiff.renderBadge.mock.calls.length).toEqual(discussionEls.length); + }); + }); + + describe('renderBadge', () => { + let discussionEls; + + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'addImageCommentBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'generateBadgeFromDiscussionDOM').mockReturnValue({ + browser: {}, + noteId: 'noteId', + }); + discussionEls = element.querySelectorAll('.note-container .discussion-notes .notes'); + imageDiff = new ImageDiff(element); + imageDiff.renderBadge(discussionEls[0], 0); + }); + + it('should populate imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(1); + }); + + describe('renderCommentBadge', () => { + beforeEach(() => { + imageDiff.renderCommentBadge = true; + imageDiff.renderBadge(discussionEls[0], 0); + }); + + it('should call addImageCommentBadge', () => { + expect(imageDiffHelper.addImageCommentBadge).toHaveBeenCalled(); + }); + }); + + describe('renderCommentBadge is false', () => { + it('should call addImageBadge', () => { + expect(imageDiffHelper.addImageBadge).toHaveBeenCalled(); + }); + }); + }); + + describe('addBadge', () => { + beforeEach(() => { + jest.spyOn(imageDiffHelper, 'addImageBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'addAvatarBadge').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame'); + imageDiff.addBadge({ + detail: { + x: 0, + y: 1, + width: 25, + height: 50, + noteId: 'noteId', + discussionId: 'discussionId', + }, + }); + }); + + it('should add imageBadge to imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(1); + }); + + it('should call addImageBadge', () => { + expect(imageDiffHelper.addImageBadge).toHaveBeenCalled(); + }); + + it('should call addAvatarBadge', () => { + expect(imageDiffHelper.addAvatarBadge).toHaveBeenCalled(); + }); + + it('should call updateDiscussionBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled(); + }); + }); + + describe('removeBadge', () => { + beforeEach(() => { + const { imageMeta } = mockData; + + jest.spyOn(imageDiffHelper, 'updateDiscussionBadgeNumber').mockImplementation(() => {}); + jest.spyOn(imageDiffHelper, 'updateDiscussionAvatarBadgeNumber').mockImplementation(() => {}); + imageDiff = new ImageDiff(element); + imageDiff.imageBadges = [imageMeta, imageMeta, imageMeta]; + imageDiff.imageFrameEl = element.querySelector('.diff-file .js-image-frame'); + imageDiff.removeBadge({ + detail: { + badgeNumber: 2, + }, + }); + }); + + describe('cascade badge count', () => { + it('should update next imageBadgeEl value', () => { + const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge'); + + expect(imageBadgeEls[0].textContent).toEqual('1'); + expect(imageBadgeEls[1].textContent).toEqual('2'); + expect(imageBadgeEls.length).toEqual(2); + }); + + it('should call updateDiscussionBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionBadgeNumber).toHaveBeenCalled(); + }); + + it('should call updateDiscussionAvatarBadgeNumber', () => { + expect(imageDiffHelper.updateDiscussionAvatarBadgeNumber).toHaveBeenCalled(); + }); + }); + + it('should remove badge from imageBadges', () => { + expect(imageDiff.imageBadges.length).toEqual(2); + }); + + it('should remove imageBadgeEl', () => { + expect(imageDiff.imageFrameEl.querySelector('#badge-2')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/image_diff/mock_data.js b/spec/frontend/image_diff/mock_data.js new file mode 100644 index 00000000000..a0d1732dd0a --- /dev/null +++ b/spec/frontend/image_diff/mock_data.js @@ -0,0 +1,28 @@ +export const noteId = 'noteId'; +export const discussionId = 'discussionId'; +export const badgeText = 'badgeText'; +export const badgeNumber = 5; + +export const coordinate = { + x: 100, + y: 100, +}; + +export const image = { + width: 100, + height: 100, +}; + +export const imageProperties = { + width: image.width, + height: image.height, + naturalWidth: image.width * 2, + naturalHeight: image.height * 2, +}; + +export const imageMeta = { + x: coordinate.x, + y: coordinate.y, + width: imageProperties.naturalWidth, + height: imageProperties.naturalHeight, +}; diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js new file mode 100644 index 00000000000..f2a7b7f8406 --- /dev/null +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -0,0 +1,356 @@ +import ReplacedImageDiff from '~/image_diff/replaced_image_diff'; +import ImageDiff from '~/image_diff/image_diff'; +import { viewTypes } from '~/image_diff/view_types'; +import imageDiffHelper from '~/image_diff/helpers/index'; + +describe('ReplacedImageDiff', () => { + let element; + let replacedImageDiff; + + beforeEach(() => { + setFixtures(` + <div id="element"> + <div class="two-up"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="swipe"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="onion-skin"> + <div class="js-image-frame"> + <img src="${gl.TEST_HOST}/image.png"> + </div> + </div> + <div class="view-modes-menu"> + <div class="two-up">2-up</div> + <div class="swipe">Swipe</div> + <div class="onion-skin">Onion skin</div> + </div> + </div> + `); + element = document.getElementById('element'); + }); + + function setupImageFrameEls() { + replacedImageDiff.imageFrameEls = []; + replacedImageDiff.imageFrameEls[viewTypes.TWO_UP] = element.querySelector( + '.two-up .js-image-frame', + ); + replacedImageDiff.imageFrameEls[viewTypes.SWIPE] = element.querySelector( + '.swipe .js-image-frame', + ); + replacedImageDiff.imageFrameEls[viewTypes.ONION_SKIN] = element.querySelector( + '.onion-skin .js-image-frame', + ); + } + + function setupViewModesEls() { + replacedImageDiff.viewModesEls = []; + replacedImageDiff.viewModesEls[viewTypes.TWO_UP] = element.querySelector( + '.view-modes-menu .two-up', + ); + replacedImageDiff.viewModesEls[viewTypes.SWIPE] = element.querySelector( + '.view-modes-menu .swipe', + ); + replacedImageDiff.viewModesEls[viewTypes.ONION_SKIN] = element.querySelector( + '.view-modes-menu .onion-skin', + ); + } + + function setupImageEls() { + replacedImageDiff.imageEls = []; + replacedImageDiff.imageEls[viewTypes.TWO_UP] = element.querySelector('.two-up img'); + replacedImageDiff.imageEls[viewTypes.SWIPE] = element.querySelector('.swipe img'); + replacedImageDiff.imageEls[viewTypes.ONION_SKIN] = element.querySelector('.onion-skin img'); + } + + it('should extend ImageDiff', () => { + replacedImageDiff = new ReplacedImageDiff(element); + + expect(replacedImageDiff instanceof ImageDiff).toEqual(true); + }); + + describe('init', () => { + beforeEach(() => { + jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + jest.spyOn(ReplacedImageDiff.prototype, 'generateImageEls').mockImplementation(() => {}); + + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.init(); + }); + + it('should set imageFrameEls', () => { + const { imageFrameEls } = replacedImageDiff; + + expect(imageFrameEls).toBeDefined(); + expect(imageFrameEls[viewTypes.TWO_UP]).toEqual( + element.querySelector('.two-up .js-image-frame'), + ); + + expect(imageFrameEls[viewTypes.SWIPE]).toEqual( + element.querySelector('.swipe .js-image-frame'), + ); + + expect(imageFrameEls[viewTypes.ONION_SKIN]).toEqual( + element.querySelector('.onion-skin .js-image-frame'), + ); + }); + + it('should set viewModesEls', () => { + const { viewModesEls } = replacedImageDiff; + + expect(viewModesEls).toBeDefined(); + expect(viewModesEls[viewTypes.TWO_UP]).toEqual( + element.querySelector('.view-modes-menu .two-up'), + ); + + expect(viewModesEls[viewTypes.SWIPE]).toEqual( + element.querySelector('.view-modes-menu .swipe'), + ); + + expect(viewModesEls[viewTypes.ONION_SKIN]).toEqual( + element.querySelector('.view-modes-menu .onion-skin'), + ); + }); + + it('should generateImageEls', () => { + expect(ReplacedImageDiff.prototype.generateImageEls).toHaveBeenCalled(); + }); + + it('should bindEvents', () => { + expect(ReplacedImageDiff.prototype.bindEvents).toHaveBeenCalled(); + }); + + describe('currentView', () => { + it('should set currentView', () => { + replacedImageDiff.init(viewTypes.ONION_SKIN); + + expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN); + }); + + it('should default to viewTypes.TWO_UP', () => { + expect(replacedImageDiff.currentView).toEqual(viewTypes.TWO_UP); + }); + }); + }); + + describe('generateImageEls', () => { + beforeEach(() => { + jest.spyOn(ReplacedImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + + replacedImageDiff = new ReplacedImageDiff(element, { + canCreateNote: false, + renderCommentBadge: false, + }); + + setupImageFrameEls(); + }); + + it('should set imageEls', () => { + replacedImageDiff.generateImageEls(); + const { imageEls } = replacedImageDiff; + + expect(imageEls).toBeDefined(); + expect(imageEls[viewTypes.TWO_UP]).toEqual(element.querySelector('.two-up img')); + expect(imageEls[viewTypes.SWIPE]).toEqual(element.querySelector('.swipe img')); + expect(imageEls[viewTypes.ONION_SKIN]).toEqual(element.querySelector('.onion-skin img')); + }); + }); + + describe('bindEvents', () => { + beforeEach(() => { + jest.spyOn(ImageDiff.prototype, 'bindEvents').mockImplementation(() => {}); + replacedImageDiff = new ReplacedImageDiff(element); + + setupViewModesEls(); + }); + + it('should call super.bindEvents', () => { + replacedImageDiff.bindEvents(); + + expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled(); + }); + + it('should register click eventlistener to 2-up view mode', done => { + jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => { + expect(viewMode).toEqual(viewTypes.TWO_UP); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click(); + }); + + it('should register click eventlistener to swipe view mode', done => { + jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => { + expect(viewMode).toEqual(viewTypes.SWIPE); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + }); + + it('should register click eventlistener to onion skin view mode', done => { + jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation(viewMode => { + expect(viewMode).toEqual(viewTypes.SWIPE); + done(); + }); + + replacedImageDiff.bindEvents(); + replacedImageDiff.viewModesEls[viewTypes.SWIPE].click(); + }); + }); + + describe('getters', () => { + describe('imageEl', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.currentView = viewTypes.TWO_UP; + setupImageEls(); + }); + + it('should return imageEl based on currentView', () => { + expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.two-up img')); + + replacedImageDiff.currentView = viewTypes.SWIPE; + + expect(replacedImageDiff.imageEl).toEqual(element.querySelector('.swipe img')); + }); + }); + + describe('imageFrameEl', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + replacedImageDiff.currentView = viewTypes.TWO_UP; + setupImageFrameEls(); + }); + + it('should return imageFrameEl based on currentView', () => { + expect(replacedImageDiff.imageFrameEl).toEqual( + element.querySelector('.two-up .js-image-frame'), + ); + + replacedImageDiff.currentView = viewTypes.ONION_SKIN; + + expect(replacedImageDiff.imageFrameEl).toEqual( + element.querySelector('.onion-skin .js-image-frame'), + ); + }); + }); + }); + + describe('changeView', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + jest.spyOn(imageDiffHelper, 'removeCommentIndicator').mockReturnValue({ + removed: false, + }); + setupImageFrameEls(); + }); + + describe('invalid viewType', () => { + beforeEach(() => { + replacedImageDiff.changeView('some-view-name'); + }); + + it('should not call removeCommentIndicator', () => { + expect(imageDiffHelper.removeCommentIndicator).not.toHaveBeenCalled(); + }); + }); + + describe('valid viewType', () => { + beforeEach(() => { + jest.spyOn(ReplacedImageDiff.prototype, 'renderNewView').mockImplementation(() => {}); + replacedImageDiff.changeView(viewTypes.ONION_SKIN); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('should call removeCommentIndicator', () => { + expect(imageDiffHelper.removeCommentIndicator).toHaveBeenCalled(); + }); + + it('should update currentView to newView', () => { + expect(replacedImageDiff.currentView).toEqual(viewTypes.ONION_SKIN); + }); + + it('should clear imageBadges', () => { + expect(replacedImageDiff.imageBadges.length).toEqual(0); + }); + + it('should call renderNewView', () => { + jest.advanceTimersByTime(251); + + expect(replacedImageDiff.renderNewView).toHaveBeenCalled(); + }); + }); + }); + + describe('renderNewView', () => { + beforeEach(() => { + replacedImageDiff = new ReplacedImageDiff(element); + }); + + it('should call renderBadges', () => { + jest.spyOn(ReplacedImageDiff.prototype, 'renderBadges').mockImplementation(() => {}); + + replacedImageDiff.renderNewView({ + removed: false, + }); + + expect(replacedImageDiff.renderBadges).toHaveBeenCalled(); + }); + + describe('removeIndicator', () => { + const indicator = { + removed: true, + x: 0, + y: 1, + image: { + width: 50, + height: 100, + }, + }; + + beforeEach(() => { + setupImageEls(); + setupImageFrameEls(); + }); + + it('should pass showCommentIndicator normalized indicator values', done => { + jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {}); + jest + .spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement') + .mockImplementation((imageEl, meta) => { + expect(meta.x).toEqual(indicator.x); + expect(meta.y).toEqual(indicator.y); + expect(meta.width).toEqual(indicator.image.width); + expect(meta.height).toEqual(indicator.image.height); + done(); + }); + replacedImageDiff.renderNewView(indicator); + }); + + it('should call showCommentIndicator', done => { + const normalized = { + normalized: true, + }; + jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized); + jest + .spyOn(imageDiffHelper, 'showCommentIndicator') + .mockImplementation((imageFrameEl, normalizedIndicator) => { + expect(normalizedIndicator).toEqual(normalized); + done(); + }); + replacedImageDiff.renderNewView(indicator); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/ci_badge_link_spec.js b/spec/frontend/vue_shared/components/ci_badge_link_spec.js new file mode 100644 index 00000000000..f656bb0b60d --- /dev/null +++ b/spec/frontend/vue_shared/components/ci_badge_link_spec.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import ciBadge from '~/vue_shared/components/ci_badge_link.vue'; + +describe('CI Badge Link Component', () => { + let CIBadge; + let vm; + + const statuses = { + canceled: { + text: 'canceled', + label: 'canceled', + group: 'canceled', + icon: 'status_canceled', + details_path: 'status/canceled', + }, + created: { + text: 'created', + label: 'created', + group: 'created', + icon: 'status_created', + details_path: 'status/created', + }, + failed: { + text: 'failed', + label: 'failed', + group: 'failed', + icon: 'status_failed', + details_path: 'status/failed', + }, + manual: { + text: 'manual', + label: 'manual action', + group: 'manual', + icon: 'status_manual', + details_path: 'status/manual', + }, + pending: { + text: 'pending', + label: 'pending', + group: 'pending', + icon: 'status_pending', + details_path: 'status/pending', + }, + running: { + text: 'running', + label: 'running', + group: 'running', + icon: 'status_running', + details_path: 'status/running', + }, + skipped: { + text: 'skipped', + label: 'skipped', + group: 'skipped', + icon: 'status_skipped', + details_path: 'status/skipped', + }, + success_warining: { + text: 'passed', + label: 'passed', + group: 'success-with-warnings', + icon: 'status_warning', + details_path: 'status/warning', + }, + success: { + text: 'passed', + label: 'passed', + group: 'passed', + icon: 'status_success', + details_path: 'status/passed', + }, + }; + + beforeEach(() => { + CIBadge = Vue.extend(ciBadge); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render each status badge', () => { + Object.keys(statuses).map(status => { + vm = mountComponent(CIBadge, { status: statuses[status] }); + + expect(vm.$el.getAttribute('href')).toEqual(statuses[status].details_path); + expect(vm.$el.textContent.trim()).toEqual(statuses[status].text); + expect(vm.$el.getAttribute('class')).toContain(`ci-status ci-${statuses[status].group}`); + expect(vm.$el.querySelector('svg')).toBeDefined(); + return vm; + }); + }); + + it('should not render label', () => { + vm = mountComponent(CIBadge, { status: statuses.canceled, showText: false }); + + expect(vm.$el.textContent.trim()).toEqual(''); + }); +}); diff --git a/spec/frontend/vue_shared/components/ci_icon_spec.js b/spec/frontend/vue_shared/components/ci_icon_spec.js new file mode 100644 index 00000000000..63afe631063 --- /dev/null +++ b/spec/frontend/vue_shared/components/ci_icon_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import ciIcon from '~/vue_shared/components/ci_icon.vue'; + +describe('CI Icon component', () => { + const Component = Vue.extend(ciIcon); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('should render a span element with an svg', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_success', + }, + }); + + expect(vm.$el.tagName).toEqual('SPAN'); + expect(vm.$el.querySelector('span > svg')).toBeDefined(); + }); + + it('should render a success status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_success', + group: 'success', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-success')).toEqual(true); + }); + + it('should render a failed status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_failed', + group: 'failed', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-failed')).toEqual(true); + }); + + it('should render success with warnings status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_warning', + group: 'warning', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-warning')).toEqual(true); + }); + + it('should render pending status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_pending', + group: 'pending', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-pending')).toEqual(true); + }); + + it('should render running status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_running', + group: 'running', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-running')).toEqual(true); + }); + + it('should render created status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_created', + group: 'created', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-created')).toEqual(true); + }); + + it('should render skipped status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_skipped', + group: 'skipped', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-skipped')).toEqual(true); + }); + + it('should render canceled status', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_canceled', + group: 'canceled', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-canceled')).toEqual(true); + }); + + it('should render status for manual action', () => { + vm = mountComponent(Component, { + status: { + icon: 'status_manual', + group: 'manual', + }, + }); + + expect(vm.$el.classList.contains('ci-status-icon-manual')).toEqual(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js new file mode 100644 index 00000000000..b0563f2f6de --- /dev/null +++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js @@ -0,0 +1,123 @@ +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; +import '~/behaviors/markdown/render_gfm'; + +describe('ContentViewer', () => { + let vm; + let mock; + + function createComponent(props) { + const ContentViewer = Vue.extend(contentViewer); + vm = mountComponent(ContentViewer, props); + } + + afterEach(() => { + vm.$destroy(); + if (mock) mock.restore(); + }); + + it('markdown preview renders + loads rendered markdown from server', done => { + mock = new MockAdapter(axios); + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).replyOnce(200, { + body: '<b>testing</b>', + }); + + createComponent({ + path: 'test.md', + content: '* Test', + projectPath: 'testproject', + type: 'markdown', + }); + + waitForPromises() + .then(() => { + expect(vm.$el.querySelector('.md-previewer').textContent).toContain('testing'); + }) + .then(done) + .catch(done.fail); + }); + + it('renders image preview', done => { + createComponent({ + path: GREEN_BOX_IMAGE_URL, + fileSize: 1024, + type: 'image', + }); + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL); + }) + .then(done) + .catch(done.fail); + }); + + it('renders fallback download control', done => { + createComponent({ + path: 'somepath/test.abc', + fileSize: 1024, + }); + + vm.$nextTick() + .then(() => { + expect( + vm.$el + .querySelector('.file-info') + .textContent.trim() + .replace(/\s+/, ' '), + ).toEqual('test.abc (1.00 KiB)'); + + expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toEqual('Download'); + }) + .then(done) + .catch(done.fail); + }); + + it('renders fallback download control for file with a data URL path properly', done => { + createComponent({ + path: 'data:application/octet-stream;base64,U0VMRUNUICfEhHNnc2cnIGZyb20gVGFibGVuYW1lOwoK', + filePath: 'somepath/test.abc', + }); + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.file-info').textContent.trim()).toEqual('test.abc'); + expect(vm.$el.querySelector('.btn.btn-default')).toHaveAttr('download', 'test.abc'); + expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toEqual('Download'); + }) + .then(done) + .catch(done.fail); + }); + + it('markdown preview receives the file path as a parameter', done => { + mock = new MockAdapter(axios); + jest.spyOn(axios, 'post'); + mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).reply(200, { + body: '<b>testing</b>', + }); + + createComponent({ + path: 'test.md', + content: '* Test', + projectPath: 'testproject', + type: 'markdown', + filePath: 'foo/test.md', + }); + + vm.$nextTick() + .then(() => { + expect(axios.post).toHaveBeenCalledWith( + `${gon.relative_url_root}/testproject/preview_markdown`, + { path: 'foo/test.md', text: '* Test' }, + expect.any(Object), + ); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js new file mode 100644 index 00000000000..636508be6b6 --- /dev/null +++ b/spec/frontend/vue_shared/components/diff_viewer/diff_viewer_spec.js @@ -0,0 +1,98 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants'; +import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; + +describe('DiffViewer', () => { + const requiredProps = { + diffMode: 'replaced', + diffViewerMode: 'image', + newPath: GREEN_BOX_IMAGE_URL, + newSha: 'ABC', + oldPath: RED_BOX_IMAGE_URL, + oldSha: 'DEF', + }; + let vm; + + function createComponent(props) { + const DiffViewer = Vue.extend(diffViewer); + + vm = mountComponent(DiffViewer, props); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders image diff', done => { + window.gon = { + relative_url_root: '', + }; + + createComponent({ ...requiredProps, projectPath: '' }); + + setImmediate(() => { + expect(vm.$el.querySelector('.deleted img').getAttribute('src')).toBe( + `//-/raw/DEF/${RED_BOX_IMAGE_URL}`, + ); + + expect(vm.$el.querySelector('.added img').getAttribute('src')).toBe( + `//-/raw/ABC/${GREEN_BOX_IMAGE_URL}`, + ); + + done(); + }); + }); + + it('renders fallback download diff display', done => { + createComponent({ + ...requiredProps, + diffViewerMode: 'added', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + setImmediate(() => { + expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain( + 'testold.abc', + ); + + expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc'); + expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain( + 'Download', + ); + + done(); + }); + }); + + it('renders renamed component', () => { + createComponent({ + ...requiredProps, + diffMode: 'renamed', + diffViewerMode: 'renamed', + newPath: 'test.abc', + oldPath: 'testold.abc', + }); + + expect(vm.$el.textContent).toContain('File moved'); + }); + + it('renders mode changed component', () => { + createComponent({ + ...requiredProps, + diffMode: 'mode_changed', + newPath: 'test.abc', + oldPath: 'testold.abc', + aMode: '123', + bMode: '321', + }); + + expect(vm.$el.textContent).toContain('File mode changed from 123 to 321'); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js new file mode 100644 index 00000000000..892a96b76fd --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; + +import { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +const defaultLabel = 'Select'; +const customLabel = 'Select project'; + +const createComponent = (props, slots = {}) => { + const Component = Vue.extend(dropdownButtonComponent); + + return mountComponentWithSlots(Component, { props, slots }); +}; + +describe('DropdownButtonComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('dropdownToggleText', () => { + it('returns default toggle text', () => { + expect(vm.toggleText).toBe(defaultLabel); + }); + + it('returns custom toggle text when provided via props', () => { + const vmEmptyLabels = createComponent({ toggleText: customLabel }); + + expect(vmEmptyLabels.toggleText).toBe(customLabel); + vmEmptyLabels.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component container element of type `button`', () => { + expect(vm.$el.nodeName).toBe('BUTTON'); + }); + + it('renders component container element with required data attributes', () => { + expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); + expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); + expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); + expect(vm.$el.dataset.labels).toBe(vm.labelsPath); + expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); + expect(vm.$el.dataset.showAny).not.toBeDefined(); + }); + + it('renders dropdown toggle text element', () => { + const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); + + expect(dropdownToggleTextEl).not.toBeNull(); + expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel); + }); + + it('renders dropdown button icon', () => { + const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa'); + + expect(dropdownIconEl).not.toBeNull(); + expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); + }); + + it('renders slot, if default slot exists', () => { + vm = createComponent( + {}, + { + default: ['Lorem Ipsum Dolar'], + }, + ); + + expect(vm.$el.querySelector('.dropdown-toggle-text')).toBeNull(); + expect(vm.$el).toHaveText('Lorem Ipsum Dolar'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js new file mode 100644 index 00000000000..30b8e869aab --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_hidden_input_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; + +import { mockLabels } from './mock_data'; + +const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => { + const Component = Vue.extend(dropdownHiddenInputComponent); + + return mountComponent(Component, { + name, + value, + }); +}; + +describe('DropdownHiddenInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders input element of type `hidden`', () => { + expect(vm.$el.nodeName).toBe('INPUT'); + expect(vm.$el.getAttribute('type')).toBe('hidden'); + expect(vm.$el.getAttribute('name')).toBe(vm.name); + expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dropdown/mock_data.js b/spec/frontend/vue_shared/components/dropdown/mock_data.js new file mode 100644 index 00000000000..b09d42da401 --- /dev/null +++ b/spec/frontend/vue_shared/components/dropdown/mock_data.js @@ -0,0 +1,11 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +export default mockLabels; diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js new file mode 100644 index 00000000000..63f2614106d --- /dev/null +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -0,0 +1,140 @@ +import Vue from 'vue'; +import { file } from 'jest/ide/helpers'; +import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; +import createComponent from 'helpers/vue_mount_component_helper'; + +describe('File finder item spec', () => { + const Component = Vue.extend(ItemComponent); + let vm; + let localFile; + + beforeEach(() => { + localFile = { + ...file(), + name: 'test file', + path: 'test/file', + }; + + vm = createComponent(Component, { + file: localFile, + focused: true, + searchText: '', + index: 0, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file name & path', () => { + expect(vm.$el.textContent).toContain('test file'); + expect(vm.$el.textContent).toContain('test/file'); + }); + + describe('focused', () => { + it('adds is-focused class', () => { + expect(vm.$el.classList).toContain('is-focused'); + }); + + it('does not have is-focused class when not focused', done => { + vm.focused = false; + + vm.$nextTick(() => { + expect(vm.$el.classList).not.toContain('is-focused'); + + done(); + }); + }); + }); + + describe('changed file icon', () => { + it('does not render when not a changed or temp file', () => { + expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null); + }); + + it('renders when a changed file', done => { + vm.file.changed = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + + done(); + }); + }); + + it('renders when a temp file', done => { + vm.file.tempFile = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null); + + done(); + }); + }); + }); + + it('emits event when clicked', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + + vm.$el.click(); + + expect(vm.$emit).toHaveBeenCalledWith('click', vm.file); + }); + + describe('path', () => { + let el; + + beforeEach(done => { + vm.searchText = 'file'; + + el = vm.$el.querySelector('.diff-changed-file-path'); + + vm.$nextTick(done); + }); + + it('highlights text', () => { + expect(el.querySelectorAll('.highlighted').length).toBe(4); + }); + + it('adds ellipsis to long text', done => { + vm.file.path = new Array(70) + .fill() + .map((_, i) => `${i}-`) + .join(''); + + vm.$nextTick(() => { + expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`); + done(); + }); + }); + }); + + describe('name', () => { + let el; + + beforeEach(done => { + vm.searchText = 'file'; + + el = vm.$el.querySelector('.diff-changed-file-name'); + + vm.$nextTick(done); + }); + + it('highlights text', () => { + expect(el.querySelectorAll('.highlighted').length).toBe(4); + }); + + it('does not add ellipsis to long text', done => { + vm.file.name = new Array(70) + .fill() + .map((_, i) => `${i}-`) + .join(''); + + vm.$nextTick(() => { + expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`); + done(); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js new file mode 100644 index 00000000000..87cafa0bb8c --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js @@ -0,0 +1,190 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import component from '~/vue_shared/components/filtered_search_dropdown.vue'; + +describe('Filtered search dropdown', () => { + const Component = Vue.extend(component); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('with an empty array of items', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [], + filterKey: '', + }); + }); + + it('renders empty list', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); + }); + + it('renders filter input', () => { + expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull(); + }); + }); + + describe('when visible numbers is less than the items length', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], + visibleItems: 2, + filterKey: 'title', + }); + }); + + it('it renders only the maximum number provided', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); + }); + }); + + describe('when visible number is bigger than the items length', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], + filterKey: 'title', + }); + }); + + it('it renders the full list of items the maximum number provided', () => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3); + }); + }); + + describe('while filtering', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + }); + }); + + it('updates the results to match the typed value', done => { + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); + done(); + }); + }); + + describe('when no value matches the typed one', () => { + it('does not render any result', done => { + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); + done(); + }); + }); + }); + }); + + describe('with create mode enabled', () => { + describe('when there are no matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + showCreateMode: true, + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('renders a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull(); + done(); + }); + }); + + it('renders computed button text', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual( + 'Create eleven', + ); + done(); + }); + }); + + describe('on click create button', () => { + it('emits createItem event with the filter', done => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$nextTick(() => { + vm.$el.querySelector('.js-dropdown-create-button').click(); + + expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven'); + done(); + }); + }); + }); + }); + + describe('when there are matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + showCreateMode: true, + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('does not render a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); + done(); + }); + }); + }); + }); + + describe('with create mode disabled', () => { + describe('when there are no matches', () => { + beforeEach(() => { + vm = mountComponent(Component, { + items: [ + { title: 'One' }, + { title: 'Two/three' }, + { title: 'Three four' }, + { title: 'Five' }, + ], + filterKey: 'title', + }); + + vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; + vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); + }); + + it('does not render a create button', done => { + vm.$nextTick(() => { + expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/gl_countdown_spec.js b/spec/frontend/vue_shared/components/gl_countdown_spec.js new file mode 100644 index 00000000000..365c9fad478 --- /dev/null +++ b/spec/frontend/vue_shared/components/gl_countdown_spec.js @@ -0,0 +1,83 @@ +import mountComponent from 'helpers/vue_mount_component_helper'; +import Vue from 'vue'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +describe('GlCountdown', () => { + const Component = Vue.extend(GlCountdown); + let vm; + let now = '2000-01-01T00:00:00Z'; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date(now).getTime()); + }); + + afterEach(() => { + vm.$destroy(); + jest.clearAllTimers(); + }); + + describe('when there is time remaining', () => { + beforeEach(done => { + vm = mountComponent(Component, { + endDateString: '2000-01-01T01:02:03Z', + }); + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays remaining time', () => { + expect(vm.$el.textContent).toContain('01:02:03'); + }); + + it('updates remaining time', done => { + now = '2000-01-01T00:00:01Z'; + jest.advanceTimersByTime(1000); + + Vue.nextTick() + .then(() => { + expect(vm.$el.textContent).toContain('01:02:02'); + done(); + }) + .catch(done.fail); + }); + }); + + describe('when there is no time remaining', () => { + beforeEach(done => { + vm = mountComponent(Component, { + endDateString: '1900-01-01T00:00:00Z', + }); + + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('displays 00:00:00', () => { + expect(vm.$el.textContent).toContain('00:00:00'); + }); + }); + + describe('when an invalid date is passed', () => { + beforeEach(() => { + Vue.config.warnHandler = jest.fn(); + }); + + afterEach(() => { + Vue.config.warnHandler = null; + }); + + it('throws a validation error', () => { + vm = mountComponent(Component, { + endDateString: 'this is invalid', + }); + + expect(Vue.config.warnHandler).toHaveBeenCalledTimes(1); + const [errorMessage] = Vue.config.warnHandler.mock.calls[0]; + + expect(errorMessage).toMatch(/^Invalid prop: .* "endDateString"/); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js new file mode 100644 index 00000000000..216563165d6 --- /dev/null +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper'; +import headerCi from '~/vue_shared/components/header_ci_component.vue'; + +describe('Header CI Component', () => { + let HeaderCi; + let vm; + let props; + + beforeEach(() => { + HeaderCi = Vue.extend(headerCi); + props = { + status: { + group: 'failed', + icon: 'status_failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + itemName: 'job', + itemId: 123, + time: '2017-05-08T14:57:39.781Z', + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + hasSidebarButton: true, + }; + }); + + afterEach(() => { + vm.$destroy(); + }); + + const findActionButtons = () => vm.$el.querySelector('.header-action-buttons'); + + describe('render', () => { + beforeEach(() => { + vm = mountComponent(HeaderCi, props); + }); + + it('should render status badge', () => { + expect(vm.$el.querySelector('.ci-failed')).toBeDefined(); + expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined(); + expect(vm.$el.querySelector('.ci-failed').getAttribute('href')).toEqual( + props.status.details_path, + ); + }); + + it('should render item name and id', () => { + expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123'); + }); + + it('should render timeago date', () => { + expect(vm.$el.querySelector('time')).toBeDefined(); + }); + + it('should render user icon and name', () => { + expect(vm.$el.querySelector('.js-user-link').innerText.trim()).toContain(props.user.name); + }); + + it('should render sidebar toggle button', () => { + expect(vm.$el.querySelector('.js-sidebar-build-toggle')).not.toBeNull(); + }); + + it('should not render header action buttons when empty', () => { + expect(findActionButtons()).toBeNull(); + }); + }); + + describe('slot', () => { + it('should render header action buttons', () => { + vm = mountComponentWithSlots(HeaderCi, { props, slots: { default: 'Test Actions' } }); + + const buttons = findActionButtons(); + + expect(buttons).not.toBeNull(); + expect(buttons.textContent).toEqual('Test Actions'); + }); + }); + + describe('shouldRenderTriggeredLabel', () => { + it('should rendered created keyword when the shouldRenderTriggeredLabel is false', () => { + vm = mountComponent(HeaderCi, { ...props, shouldRenderTriggeredLabel: false }); + + expect(vm.$el.textContent).toContain('created'); + expect(vm.$el.textContent).not.toContain('triggered'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js new file mode 100644 index 00000000000..e7c31014bfc --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; + +describe('toolbar', () => { + let vm; + const Toolbar = Vue.extend(toolbar); + const props = { + markdownDocsPath: '', + }; + + afterEach(() => { + vm.$destroy(); + }); + + describe('user can attach file', () => { + beforeEach(() => { + vm = mountComponent(Toolbar, props); + }); + + it('should render uploading-container', () => { + expect(vm.$el.querySelector('.uploading-container')).not.toBeNull(); + }); + }); + + describe('user cannot attach file', () => { + beforeEach(() => { + vm = mountComponent(Toolbar, { ...props, canAttachFile: false }); + }); + + it('should not render uploading-container', () => { + expect(vm.$el.querySelector('.uploading-container')).toBeNull(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/navigation_tabs_spec.js b/spec/frontend/vue_shared/components/navigation_tabs_spec.js new file mode 100644 index 00000000000..561456d614e --- /dev/null +++ b/spec/frontend/vue_shared/components/navigation_tabs_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import navigationTabs from '~/vue_shared/components/navigation_tabs.vue'; + +describe('navigation tabs component', () => { + let vm; + let Component; + let data; + + beforeEach(() => { + data = [ + { + name: 'All', + scope: 'all', + count: 1, + isActive: true, + }, + { + name: 'Pending', + scope: 'pending', + count: 0, + isActive: false, + }, + { + name: 'Running', + scope: 'running', + isActive: false, + }, + ]; + + Component = Vue.extend(navigationTabs); + vm = mountComponent(Component, { tabs: data, scope: 'pipelines' }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render tabs', () => { + expect(vm.$el.querySelectorAll('li').length).toEqual(data.length); + }); + + it('should render active tab', () => { + expect(vm.$el.querySelector('.active .js-pipelines-tab-all')).toBeDefined(); + }); + + it('should render badge', () => { + expect(vm.$el.querySelector('.js-pipelines-tab-all .badge').textContent.trim()).toEqual('1'); + expect(vm.$el.querySelector('.js-pipelines-tab-pending .badge').textContent.trim()).toEqual( + '0', + ); + }); + + it('should not render badge', () => { + expect(vm.$el.querySelector('.js-pipelines-tab-running .badge')).toEqual(null); + }); + + it('should trigger onTabClick', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.$el.querySelector('.js-pipelines-tab-pending').click(); + + expect(vm.$emit).toHaveBeenCalledWith('onChangeTab', 'pending'); + }); +}); diff --git a/spec/frontend/vue_shared/components/pikaday_spec.js b/spec/frontend/vue_shared/components/pikaday_spec.js new file mode 100644 index 00000000000..867bf88ff50 --- /dev/null +++ b/spec/frontend/vue_shared/components/pikaday_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import datePicker from '~/vue_shared/components/pikaday.vue'; + +describe('datePicker', () => { + let vm; + beforeEach(() => { + const DatePicker = Vue.extend(datePicker); + vm = mountComponent(DatePicker, { + label: 'label', + }); + }); + + it('should render label text', () => { + expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label'); + }); + + it('should show calendar', () => { + expect(vm.$el.querySelector('.pika-single')).toBeDefined(); + }); + + it('should toggle when dropdown is clicked', () => { + const hidePicker = jest.fn(); + vm.$on('hidePicker', hidePicker); + + vm.$el.querySelector('.dropdown-menu-toggle').click(); + + expect(hidePicker).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js new file mode 100644 index 00000000000..090f8b69213 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_avatar/default_spec.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import { projectData } from 'jest/ide/mock_data'; +import { TEST_HOST } from 'spec/test_constants'; +import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; +import ProjectAvatarDefault from '~/vue_shared/components/project_avatar/default.vue'; + +describe('ProjectAvatarDefault component', () => { + const Component = Vue.extend(ProjectAvatarDefault); + let vm; + + beforeEach(() => { + vm = mountComponent(Component, { + project: projectData, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders identicon if project has no avatar_url', done => { + const expectedText = getFirstCharacterCapitalized(projectData.name); + + vm.project = { + ...vm.project, + avatar_url: null, + }; + + vm.$nextTick() + .then(() => { + const identiconEl = vm.$el.querySelector('.identicon'); + + expect(identiconEl).not.toBe(null); + expect(identiconEl.textContent.trim()).toEqual(expectedText); + }) + .then(done) + .catch(done.fail); + }); + + it('renders avatar image if project has avatar_url', done => { + const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; + + vm.project = { + ...vm.project, + avatar_url: avatarUrl, + }; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.avatar')).not.toBeNull(); + expect(vm.$el.querySelector('.identicon')).toBeNull(); + expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); + }) + .then(done) + .catch(done.fail); + }); +}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js new file mode 100644 index 00000000000..eb1d9e93634 --- /dev/null +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -0,0 +1,109 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; +import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; + +const localVue = createLocalVue(); + +describe('ProjectListItem component', () => { + const Component = localVue.extend(ProjectListItem); + let wrapper; + let vm; + let options; + + const project = getJSONFixture('static/projects.json')[0]; + + beforeEach(() => { + options = { + propsData: { + project, + selected: false, + }, + localVue, + }; + }); + + afterEach(() => { + wrapper.vm.$destroy(); + }); + + it('does not render a check mark icon if selected === false', () => { + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true); + }); + + it('renders a check mark icon if selected === true', () => { + options.propsData.selected = true; + + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true); + }); + + it(`emits a "clicked" event when clicked`, () => { + wrapper = shallowMount(Component, options); + ({ vm } = wrapper); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + wrapper.vm.onClick(); + + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + }); + + it(`renders the project avatar`, () => { + wrapper = shallowMount(Component, options); + + expect(wrapper.contains('.js-project-avatar')).toBe(true); + }); + + it(`renders a simple namespace name with a trailing slash`, () => { + options.propsData.project.name_with_namespace = 'a / b'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('a /'); + }); + + it(`renders a properly truncated namespace with a trailing slash`, () => { + options.propsData.project.name_with_namespace = 'a / b / c / d / e / f'; + + wrapper = shallowMount(Component, options); + const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text()); + + expect(renderedNamespace).toBe('a / ... / e /'); + }); + + it(`renders the project name`, () => { + options.propsData.project.name = 'my-test-project'; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').text()); + + expect(renderedName).toBe('my-test-project'); + }); + + it(`renders the project name with highlighting in the case of a search query match`, () => { + options.propsData.project.name = 'my-test-project'; + options.propsData.matcher = 'pro'; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').html()); + const expected = 'my-test-<b>p</b><b>r</b><b>o</b>ject'; + + expect(renderedName).toContain(expected); + }); + + it('prevents search query and project name XSS', () => { + const alertSpy = jest.spyOn(window, 'alert'); + options.propsData.project.name = "my-xss-pro<script>alert('XSS');</script>ject"; + options.propsData.matcher = "pro<script>alert('XSS');</script>"; + + wrapper = shallowMount(Component, options); + const renderedName = trimText(wrapper.find('.js-project-name').html()); + const expected = 'my-xss-project'; + + expect(renderedName).toContain(expected); + expect(alertSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js index d90fafb6bf7..9db86fa775f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js @@ -4,10 +4,7 @@ import { shallowMount } from '@vue/test-utils'; import LabelsSelect from '~/labels_select'; import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = (config = mockConfig) => shallowMount(BaseComponent, { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index a4121448492..d02d924bd2b 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -3,10 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const componentConfig = { ...mockConfig, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index d0299523137..edec3b138b3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; -import { mockSuggestedColors } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockSuggestedColors } from './mock_data'; const createComponent = headerTitle => { const Component = Vue.extend(dropdownCreateLabelComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js index 784bbaf8e6a..7e9e242a4f5 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; -import { mockConfig } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig } from './mock_data'; const createComponent = ( labelsWebUrl = mockConfig.labelsWebUrl, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 887c04268d1..e09f0006359 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; -import { mockLabels } from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 06355c0dd65..c33cffb421d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -2,10 +2,7 @@ import { mount } from '@vue/test-utils'; import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; import { GlLabel } from '@gitlab/ui'; -import { - mockConfig, - mockLabels, -} from '../../../../../javascripts/vue_shared/components/sidebar/labels_select/mock_data'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = ( labels = mockLabels, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js new file mode 100644 index 00000000000..6564c012e67 --- /dev/null +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js @@ -0,0 +1,57 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#FFFFFF', + }, +]; + +export const mockSuggestedColors = [ + '#0033CC', + '#428BCA', + '#44AD8E', + '#A8D695', + '#5CB85C', + '#69D100', + '#004E00', + '#34495E', + '#7F8C8D', + '#A295D6', + '#5843AD', + '#8E44AD', + '#FFECDB', + '#AD4363', + '#D10069', + '#CC0033', + '#FF0000', + '#D9534F', + '#D1D100', + '#F0AD4E', + '#AD8D43', +]; + +export const mockConfig = { + showCreate: true, + isProject: true, + abilityName: 'issue', + context: { + labels: mockLabels, + }, + namespace: 'gitlab-org', + updatePath: '/gitlab-org/my-project/issue/1', + labelsPath: '/gitlab-org/my-project/-/labels.json', + labelsWebUrl: '/gitlab-org/my-project/-/labels', + labelFilterBasePath: '/gitlab-org/my-project/issues', + canEdit: true, + suggestedColors: mockSuggestedColors, + emptyValueText: 'None', +}; diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js new file mode 100644 index 00000000000..bc86ee5a0c6 --- /dev/null +++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js @@ -0,0 +1,104 @@ +import Vue from 'vue'; + +import mountComponent from 'helpers/vue_mount_component_helper'; +import stackedProgressBarComponent from '~/vue_shared/components/stacked_progress_bar.vue'; + +const createComponent = config => { + const Component = Vue.extend(stackedProgressBarComponent); + const defaultConfig = { + successLabel: 'Synced', + failureLabel: 'Failed', + neutralLabel: 'Out of sync', + successCount: 25, + failureCount: 10, + totalCount: 5000, + ...config, + }; + + return mountComponent(Component, defaultConfig); +}; + +describe('StackedProgressBarComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('neutralCount', () => { + it('returns neutralCount based on totalCount, successCount and failureCount', () => { + expect(vm.neutralCount).toBe(4965); // 5000 - 25 - 10 + }); + }); + }); + + describe('methods', () => { + describe('getPercent', () => { + it('returns percentage from provided count based on `totalCount`', () => { + expect(vm.getPercent(500)).toBe(10); + }); + + it('returns percentage with decimal place from provided count based on `totalCount`', () => { + expect(vm.getPercent(67)).toBe(1.3); + }); + + it('returns percentage as `< 1` from provided count based on `totalCount` when evaluated value is less than 1', () => { + expect(vm.getPercent(10)).toBe('< 1'); + }); + + it('returns 0 if totalCount is falsy', () => { + vm = createComponent({ totalCount: 0 }); + + expect(vm.getPercent(100)).toBe(0); + }); + }); + + describe('barStyle', () => { + it('returns style string based on percentage provided', () => { + expect(vm.barStyle(50)).toBe('width: 50%;'); + }); + }); + + describe('getTooltip', () => { + describe('when hideTooltips is false', () => { + it('returns label string based on label and count provided', () => { + expect(vm.getTooltip('Synced', 10)).toBe('Synced: 10'); + }); + }); + + describe('when hideTooltips is true', () => { + beforeEach(() => { + vm = createComponent({ hideTooltips: true }); + }); + + it('returns an empty string', () => { + expect(vm.getTooltip('Synced', 10)).toBe(''); + }); + }); + }); + }); + + describe('template', () => { + it('renders container element', () => { + expect(vm.$el.classList.contains('stacked-progress-bar')).toBeTruthy(); + }); + + it('renders empty state when count is unavailable', () => { + const vmX = createComponent({ totalCount: 0, successCount: 0, failureCount: 0 }); + + expect(vmX.$el.querySelectorAll('.status-unavailable').length).not.toBe(0); + vmX.$destroy(); + }); + + it('renders bar elements when count is available', () => { + expect(vm.$el.querySelectorAll('.status-green').length).not.toBe(0); + expect(vm.$el.querySelectorAll('.status-neutral').length).not.toBe(0); + expect(vm.$el.querySelectorAll('.status-red').length).not.toBe(0); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/tabs/tab_spec.js b/spec/frontend/vue_shared/components/tabs/tab_spec.js new file mode 100644 index 00000000000..8cf07a9177c --- /dev/null +++ b/spec/frontend/vue_shared/components/tabs/tab_spec.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; + +describe('Tab component', () => { + const Component = Vue.extend(Tab); + let vm; + + beforeEach(() => { + vm = mountComponent(Component); + }); + + it('sets localActive to equal active', done => { + vm.active = true; + + vm.$nextTick(() => { + expect(vm.localActive).toBe(true); + + done(); + }); + }); + + it('sets active class', done => { + vm.active = true; + + vm.$nextTick(() => { + expect(vm.$el.classList).toContain('active'); + + done(); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/tabs/tabs_spec.js b/spec/frontend/vue_shared/components/tabs/tabs_spec.js new file mode 100644 index 00000000000..49d92094b34 --- /dev/null +++ b/spec/frontend/vue_shared/components/tabs/tabs_spec.js @@ -0,0 +1,61 @@ +import Vue from 'vue'; +import Tabs from '~/vue_shared/components/tabs/tabs'; +import Tab from '~/vue_shared/components/tabs/tab.vue'; + +describe('Tabs component', () => { + let vm; + + beforeEach(() => { + vm = new Vue({ + components: { + Tabs, + Tab, + }, + render(h) { + return h('div', [ + h('tabs', [ + h('tab', { attrs: { title: 'Testing', active: true } }, 'First tab'), + h('tab', [h('template', { slot: 'title' }, 'Test slot'), 'Second tab']), + ]), + ]); + }, + }).$mount(); + + return vm.$nextTick(); + }); + + describe('tab links', () => { + it('renders links for tabs', () => { + expect(vm.$el.querySelectorAll('a').length).toBe(2); + }); + + it('renders link titles from props', () => { + expect(vm.$el.querySelector('a').textContent).toContain('Testing'); + }); + + it('renders link titles from slot', () => { + expect(vm.$el.querySelectorAll('a')[1].textContent).toContain('Test slot'); + }); + + it('renders active class', () => { + expect(vm.$el.querySelector('a').classList).toContain('active'); + }); + + it('updates active class on click', () => { + vm.$el.querySelectorAll('a')[1].click(); + + return vm.$nextTick(() => { + expect(vm.$el.querySelector('a').classList).not.toContain('active'); + expect(vm.$el.querySelectorAll('a')[1].classList).toContain('active'); + }); + }); + }); + + describe('content', () => { + it('renders content panes', () => { + expect(vm.$el.querySelectorAll('.tab-pane').length).toBe(2); + expect(vm.$el.querySelectorAll('.tab-pane')[0].textContent).toContain('First tab'); + expect(vm.$el.querySelectorAll('.tab-pane')[1].textContent).toContain('Second tab'); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js new file mode 100644 index 00000000000..83bbb37a89a --- /dev/null +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper'; +import toggleButton from '~/vue_shared/components/toggle_button.vue'; + +describe('Toggle Button', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(toggleButton); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('render output', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + name: 'foo', + }); + }); + + it('renders input with provided name', () => { + expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo'); + }); + + it('renders input with provided value', () => { + expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); + }); + + it('renders input status icon', () => { + expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); + expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1); + }); + }); + + describe('is-checked', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + }); + + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('renders is checked class', () => { + expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); + }); + + it('sets aria-label representing toggle state', () => { + vm.value = true; + + expect(vm.ariaLabel).toEqual('Toggle Status: ON'); + + vm.value = false; + + expect(vm.ariaLabel).toEqual('Toggle Status: OFF'); + }); + + it('emits change event when clicked', () => { + vm.$el.querySelector('button').click(); + + expect(vm.$emit).toHaveBeenCalledWith('change', false); + }); + }); + + describe('is-disabled', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + disabledInput: true, + }); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + }); + + it('renders disabled button', () => { + expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true); + }); + + it('does not emit change event when clicked', () => { + vm.$el.querySelector('button').click(); + + expect(vm.$emit).not.toHaveBeenCalled(); + }); + }); + + describe('is-loading', () => { + beforeEach(() => { + vm = mountComponent(Component, { + value: true, + isLoading: true, + }); + }); + + it('renders loading class', () => { + expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js new file mode 100644 index 00000000000..ee6c2e2cc46 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js @@ -0,0 +1,27 @@ +import { shallowMount } from '@vue/test-utils'; +import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue'; + +describe('User Avatar Svg Component', () => { + describe('Initialization', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallowMount(UserAvatarSvg, { + propsData: { + size: 99, + svg: + '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.707 15.707C1.077z"/></svg>', + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should have <svg> as a child element', () => { + expect(wrapper.element.tagName).toEqual('svg'); + expect(wrapper.html()).toContain('<path'); + }); + }); +}); |