summaryrefslogtreecommitdiff
path: root/spec/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/.eslintrc.yml9
-rw-r--r--spec/frontend/behaviors/secret_values_spec.js230
-rw-r--r--spec/frontend/blob/blob_fork_suggestion_spec.js39
-rw-r--r--spec/frontend/boards/modal_store_spec.js134
-rw-r--r--spec/frontend/cycle_analytics/limit_warning_component_spec.js42
-rw-r--r--spec/frontend/diffs/components/diff_stats_spec.js33
-rw-r--r--spec/frontend/diffs/components/edit_button_spec.js61
-rw-r--r--spec/frontend/diffs/components/hidden_files_warning_spec.js48
-rw-r--r--spec/frontend/diffs/components/no_changes_spec.js40
-rw-r--r--spec/frontend/environment.js33
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js118
-rw-r--r--spec/frontend/error_tracking/store/mutation_spec.js36
-rw-r--r--spec/frontend/filtered_search/filtered_search_token_keys_spec.js141
-rw-r--r--spec/frontend/filtered_search/services/recent_searches_service_error_spec.js18
-rw-r--r--spec/frontend/filtered_search/stores/recent_searches_store_spec.js53
-rw-r--r--spec/frontend/frequent_items/store/getters_spec.js24
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js99
-rw-r--r--spec/frontend/helpers/class_spec_helper.js9
-rw-r--r--spec/frontend/helpers/fixtures.js36
-rw-r--r--spec/frontend/helpers/locale_helper.js11
-rw-r--r--spec/frontend/helpers/scroll_into_view_promise.js28
-rw-r--r--spec/frontend/helpers/set_timeout_promise_helper.js4
-rw-r--r--spec/frontend/helpers/user_mock_data_helper.js14
-rw-r--r--spec/frontend/helpers/vue_component_helper.js18
-rw-r--r--spec/frontend/helpers/vue_mount_component_helper.js38
-rw-r--r--spec/frontend/helpers/vue_resource_helper.js11
-rw-r--r--spec/frontend/helpers/vue_test_utils_helper.js19
-rw-r--r--spec/frontend/helpers/vuex_action_helper.js104
-rw-r--r--spec/frontend/helpers/wait_for_attribute_change.js16
-rw-r--r--spec/frontend/helpers/wait_for_promises.js1
-rw-r--r--spec/frontend/ide/lib/common/disposable_spec.js44
-rw-r--r--spec/frontend/ide/lib/diff/diff_spec.js80
-rw-r--r--spec/frontend/ide/lib/editor_options_spec.js11
-rw-r--r--spec/frontend/ide/lib/files_spec.js77
-rw-r--r--spec/frontend/ide/stores/modules/commit/mutations_spec.js42
-rw-r--r--spec/frontend/ide/stores/modules/file_templates/getters_spec.js59
-rw-r--r--spec/frontend/ide/stores/modules/file_templates/mutations_spec.js69
-rw-r--r--spec/frontend/ide/stores/modules/pane/getters_spec.js55
-rw-r--r--spec/frontend/ide/stores/modules/pane/mutations_spec.js42
-rw-r--r--spec/frontend/ide/stores/modules/pipelines/getters_spec.js40
-rw-r--r--spec/frontend/ide/stores/mutations/branch_spec.js40
-rw-r--r--spec/frontend/ide/stores/mutations/merge_request_spec.js67
-rw-r--r--spec/frontend/image_diff/view_types_spec.js24
-rw-r--r--spec/frontend/import_projects/store/getters_spec.js83
-rw-r--r--spec/frontend/import_projects/store/mutations_spec.js34
-rw-r--r--spec/frontend/jobs/components/empty_state_spec.js93
-rw-r--r--spec/frontend/jobs/components/erased_block_spec.js56
-rw-r--r--spec/frontend/jobs/components/sidebar_detail_row_spec.js61
-rw-r--r--spec/frontend/jobs/components/stuck_block_spec.js81
-rw-r--r--spec/frontend/jobs/store/getters_spec.js243
-rw-r--r--spec/frontend/jobs/store/mutations_spec.js230
-rw-r--r--spec/frontend/labels_select_spec.js52
-rw-r--r--spec/frontend/lib/utils/autosave_spec.js64
-rw-r--r--spec/frontend/lib/utils/cache_spec.js65
-rw-r--r--spec/frontend/lib/utils/grammar_spec.js35
-rw-r--r--spec/frontend/lib/utils/image_utility_spec.js32
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js101
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js154
-rw-r--r--spec/frontend/locale/ensure_single_line_spec.js38
-rw-r--r--spec/frontend/locale/sprintf_spec.js74
-rw-r--r--spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap93
-rw-r--r--spec/frontend/mr_popover/mr_popover_spec.js61
-rw-r--r--spec/frontend/notebook/lib/highlight_spec.js15
-rw-r--r--spec/frontend/notes/components/discussion_reply_placeholder_spec.js34
-rw-r--r--spec/frontend/notes/components/discussion_resolve_button_spec.js74
-rw-r--r--spec/frontend/notes/components/note_attachment_spec.js23
-rw-r--r--spec/frontend/notes/components/note_edited_text_spec.js47
-rw-r--r--spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js46
-rw-r--r--spec/frontend/performance_bar/services/performance_bar_service_spec.js68
-rw-r--r--spec/frontend/pipelines/blank_state_spec.js25
-rw-r--r--spec/frontend/pipelines/empty_state_spec.js58
-rw-r--r--spec/frontend/pipelines/pipeline_store_spec.js27
-rw-r--r--spec/frontend/pipelines/pipelines_store_spec.js77
-rw-r--r--spec/frontend/registry/getters_spec.js46
-rw-r--r--spec/frontend/reports/components/report_link_spec.js69
-rw-r--r--spec/frontend/reports/store/utils_spec.js138
-rw-r--r--spec/frontend/sidebar/confidential_edit_buttons_spec.js35
-rw-r--r--spec/frontend/sidebar/confidential_edit_form_buttons_spec.js35
-rw-r--r--spec/frontend/sidebar/lock/edit_form_spec.js37
-rw-r--r--spec/frontend/test_setup.js26
-rw-r--r--spec/frontend/u2f/util_spec.js61
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js51
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js30
-rw-r--r--spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js85
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js61
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js136
-rw-r--r--spec/frontend/vue_mr_widget/stores/get_state_key_spec.js103
-rw-r--r--spec/frontend/vue_shared/components/callout_spec.js66
-rw-r--r--spec/frontend/vue_shared/components/code_block_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/identicon_spec.js65
-rw-r--r--spec/frontend/vue_shared/components/lib/utils/dom_utils_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/pagination_links_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/time_ago_tooltip_spec.js44
-rw-r--r--spec/frontend/vuex_shared/modules/modal/mutations_spec.js49
95 files changed, 5522 insertions, 4 deletions
diff --git a/spec/frontend/.eslintrc.yml b/spec/frontend/.eslintrc.yml
index 046215e4c93..054dc27cda6 100644
--- a/spec/frontend/.eslintrc.yml
+++ b/spec/frontend/.eslintrc.yml
@@ -2,8 +2,13 @@
env:
jest/globals: true
plugins:
-- jest
+ - jest
settings:
import/resolver:
jest:
- jestConfigFile: "jest.config.js"
+ jestConfigFile: 'jest.config.js'
+globals:
+ getJSONFixture: false
+ loadFixtures: false
+ preloadFixtures: false
+ setFixtures: false
diff --git a/spec/frontend/behaviors/secret_values_spec.js b/spec/frontend/behaviors/secret_values_spec.js
new file mode 100644
index 00000000000..5aaab093c0c
--- /dev/null
+++ b/spec/frontend/behaviors/secret_values_spec.js
@@ -0,0 +1,230 @@
+import SecretValues from '~/behaviors/secret_values';
+
+function generateValueMarkup(
+ secret,
+ valueClass = 'js-secret-value',
+ placeholderClass = 'js-secret-value-placeholder',
+) {
+ return `
+ <div class="${placeholderClass}">
+ ***
+ </div>
+ <div class="hidden ${valueClass}">
+ ${secret}
+ </div>
+ `;
+}
+
+function generateFixtureMarkup(secrets, isRevealed, valueClass, placeholderClass) {
+ return `
+ <div class="js-secret-container">
+ ${secrets.map(secret => generateValueMarkup(secret, valueClass, placeholderClass)).join('')}
+ <button
+ class="js-secret-value-reveal-button"
+ data-secret-reveal-status="${isRevealed}"
+ >
+ ...
+ </button>
+ </div>
+ `;
+}
+
+function setupSecretFixture(
+ secrets,
+ isRevealed,
+ valueClass = 'js-secret-value',
+ placeholderClass = 'js-secret-value-placeholder',
+) {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = generateFixtureMarkup(secrets, isRevealed, valueClass, placeholderClass);
+
+ const secretValues = new SecretValues({
+ container: wrapper.querySelector('.js-secret-container'),
+ valueSelector: `.${valueClass}`,
+ placeholderSelector: `.${placeholderClass}`,
+ });
+ secretValues.init();
+
+ return wrapper;
+}
+
+describe('setupSecretValues', () => {
+ describe('with a single secret', () => {
+ const secrets = ['mysecret123'];
+
+ it('should have correct "Reveal" label when values are hidden', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+
+ expect(revealButton.textContent).toEqual('Reveal value');
+ });
+
+ it('should have correct "Hide" label when values are shown', () => {
+ const wrapper = setupSecretFixture(secrets, true);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+
+ expect(revealButton.textContent).toEqual('Hide value');
+ });
+
+ it('should have value hidden initially', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ const values = wrapper.querySelectorAll('.js-secret-value');
+ const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
+
+ expect(values.length).toEqual(1);
+ expect(values[0].classList.contains('hide')).toEqual(true);
+ expect(placeholders.length).toEqual(1);
+ expect(placeholders[0].classList.contains('hide')).toEqual(false);
+ });
+
+ it('should toggle value and placeholder', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+ const values = wrapper.querySelectorAll('.js-secret-value');
+ const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
+
+ revealButton.click();
+
+ expect(values.length).toEqual(1);
+ expect(values[0].classList.contains('hide')).toEqual(false);
+ expect(placeholders.length).toEqual(1);
+ expect(placeholders[0].classList.contains('hide')).toEqual(true);
+
+ revealButton.click();
+
+ expect(values.length).toEqual(1);
+ expect(values[0].classList.contains('hide')).toEqual(true);
+ expect(placeholders.length).toEqual(1);
+ expect(placeholders[0].classList.contains('hide')).toEqual(false);
+ });
+ });
+
+ describe('with a multiple secrets', () => {
+ const secrets = ['mysecret123', 'happygoat456', 'tanuki789'];
+
+ it('should have correct "Reveal" label when values are hidden', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+
+ expect(revealButton.textContent).toEqual('Reveal values');
+ });
+
+ it('should have correct "Hide" label when values are shown', () => {
+ const wrapper = setupSecretFixture(secrets, true);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+
+ expect(revealButton.textContent).toEqual('Hide values');
+ });
+
+ it('should have all values hidden initially', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ const values = wrapper.querySelectorAll('.js-secret-value');
+ const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
+
+ expect(values.length).toEqual(3);
+ values.forEach(value => {
+ expect(value.classList.contains('hide')).toEqual(true);
+ });
+
+ expect(placeholders.length).toEqual(3);
+ placeholders.forEach(placeholder => {
+ expect(placeholder.classList.contains('hide')).toEqual(false);
+ });
+ });
+
+ it('should toggle values and placeholders', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+ const values = wrapper.querySelectorAll('.js-secret-value');
+ const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
+
+ revealButton.click();
+
+ expect(values.length).toEqual(3);
+ values.forEach(value => {
+ expect(value.classList.contains('hide')).toEqual(false);
+ });
+
+ expect(placeholders.length).toEqual(3);
+ placeholders.forEach(placeholder => {
+ expect(placeholder.classList.contains('hide')).toEqual(true);
+ });
+
+ revealButton.click();
+
+ expect(values.length).toEqual(3);
+ values.forEach(value => {
+ expect(value.classList.contains('hide')).toEqual(true);
+ });
+
+ expect(placeholders.length).toEqual(3);
+ placeholders.forEach(placeholder => {
+ expect(placeholder.classList.contains('hide')).toEqual(false);
+ });
+ });
+ });
+
+ describe('with dynamic secrets', () => {
+ const secrets = ['mysecret123', 'happygoat456', 'tanuki789'];
+
+ it('should toggle values and placeholders', () => {
+ const wrapper = setupSecretFixture(secrets, false);
+ // Insert the new dynamic row
+ wrapper
+ .querySelector('.js-secret-container')
+ .insertAdjacentHTML('afterbegin', generateValueMarkup('foobarbazdynamic'));
+
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+ const values = wrapper.querySelectorAll('.js-secret-value');
+ const placeholders = wrapper.querySelectorAll('.js-secret-value-placeholder');
+
+ revealButton.click();
+
+ expect(values.length).toEqual(4);
+ values.forEach(value => {
+ expect(value.classList.contains('hide')).toEqual(false);
+ });
+
+ expect(placeholders.length).toEqual(4);
+ placeholders.forEach(placeholder => {
+ expect(placeholder.classList.contains('hide')).toEqual(true);
+ });
+
+ revealButton.click();
+
+ expect(values.length).toEqual(4);
+ values.forEach(value => {
+ expect(value.classList.contains('hide')).toEqual(true);
+ });
+
+ expect(placeholders.length).toEqual(4);
+ placeholders.forEach(placeholder => {
+ expect(placeholder.classList.contains('hide')).toEqual(false);
+ });
+ });
+ });
+
+ describe('selector options', () => {
+ const secrets = ['mysecret123'];
+
+ it('should respect `valueSelector` and `placeholderSelector` options', () => {
+ const valueClass = 'js-some-custom-placeholder-selector';
+ const placeholderClass = 'js-some-custom-value-selector';
+
+ const wrapper = setupSecretFixture(secrets, false, valueClass, placeholderClass);
+ const values = wrapper.querySelectorAll(`.${valueClass}`);
+ const placeholders = wrapper.querySelectorAll(`.${placeholderClass}`);
+ const revealButton = wrapper.querySelector('.js-secret-value-reveal-button');
+
+ expect(values.length).toEqual(1);
+ expect(placeholders.length).toEqual(1);
+
+ revealButton.click();
+
+ expect(values.length).toEqual(1);
+ expect(values[0].classList.contains('hide')).toEqual(false);
+ expect(placeholders.length).toEqual(1);
+ expect(placeholders[0].classList.contains('hide')).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/blob/blob_fork_suggestion_spec.js b/spec/frontend/blob/blob_fork_suggestion_spec.js
new file mode 100644
index 00000000000..9b81b7e6f92
--- /dev/null
+++ b/spec/frontend/blob/blob_fork_suggestion_spec.js
@@ -0,0 +1,39 @@
+import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
+
+describe('BlobForkSuggestion', () => {
+ let blobForkSuggestion;
+
+ const openButton = document.createElement('div');
+ const forkButton = document.createElement('a');
+ const cancelButton = document.createElement('div');
+ const suggestionSection = document.createElement('div');
+ const actionTextPiece = document.createElement('div');
+
+ beforeEach(() => {
+ blobForkSuggestion = new BlobForkSuggestion({
+ openButtons: openButton,
+ forkButtons: forkButton,
+ cancelButtons: cancelButton,
+ suggestionSections: suggestionSection,
+ actionTextPieces: actionTextPiece,
+ }).init();
+ });
+
+ afterEach(() => {
+ blobForkSuggestion.destroy();
+ });
+
+ it('showSuggestionSection', () => {
+ blobForkSuggestion.showSuggestionSection('/foo', 'foo');
+
+ expect(suggestionSection.classList.contains('hidden')).toEqual(false);
+ expect(forkButton.getAttribute('href')).toEqual('/foo');
+ expect(actionTextPiece.textContent).toEqual('foo');
+ });
+
+ it('hideSuggestionSection', () => {
+ blobForkSuggestion.hideSuggestionSection();
+
+ expect(suggestionSection.classList.contains('hidden')).toEqual(true);
+ });
+});
diff --git a/spec/frontend/boards/modal_store_spec.js b/spec/frontend/boards/modal_store_spec.js
new file mode 100644
index 00000000000..3257a3fb8a3
--- /dev/null
+++ b/spec/frontend/boards/modal_store_spec.js
@@ -0,0 +1,134 @@
+/* global ListIssue */
+
+import '~/vue_shared/models/label';
+import '~/vue_shared/models/assignee';
+import '~/boards/models/issue';
+import '~/boards/models/list';
+import Store from '~/boards/stores/modal_store';
+
+describe('Modal store', () => {
+ let issue;
+ let issue2;
+
+ beforeEach(() => {
+ // Set up default state
+ Store.store.issues = [];
+ Store.store.selectedIssues = [];
+
+ issue = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 1,
+ confidential: false,
+ labels: [],
+ assignees: [],
+ });
+ issue2 = new ListIssue({
+ title: 'Testing',
+ id: 1,
+ iid: 2,
+ confidential: false,
+ labels: [],
+ assignees: [],
+ });
+ Store.store.issues.push(issue);
+ Store.store.issues.push(issue2);
+ });
+
+ it('returns selected count', () => {
+ expect(Store.selectedCount()).toBe(0);
+ });
+
+ it('toggles the issue as selected', () => {
+ Store.toggleIssue(issue);
+
+ expect(issue.selected).toBe(true);
+ expect(Store.selectedCount()).toBe(1);
+ });
+
+ it('toggles the issue as un-selected', () => {
+ Store.toggleIssue(issue);
+ Store.toggleIssue(issue);
+
+ expect(issue.selected).toBe(false);
+ expect(Store.selectedCount()).toBe(0);
+ });
+
+ it('toggles all issues as selected', () => {
+ Store.toggleAll();
+
+ expect(issue.selected).toBe(true);
+ expect(issue2.selected).toBe(true);
+ expect(Store.selectedCount()).toBe(2);
+ });
+
+ it('toggles all issues as un-selected', () => {
+ Store.toggleAll();
+ Store.toggleAll();
+
+ expect(issue.selected).toBe(false);
+ expect(issue2.selected).toBe(false);
+ expect(Store.selectedCount()).toBe(0);
+ });
+
+ it('toggles all if a single issue is selected', () => {
+ Store.toggleIssue(issue);
+ Store.toggleAll();
+
+ expect(issue.selected).toBe(true);
+ expect(issue2.selected).toBe(true);
+ expect(Store.selectedCount()).toBe(2);
+ });
+
+ it('adds issue to selected array', () => {
+ issue.selected = true;
+ Store.addSelectedIssue(issue);
+
+ expect(Store.selectedCount()).toBe(1);
+ });
+
+ it('removes issue from selected array', () => {
+ Store.addSelectedIssue(issue);
+ Store.removeSelectedIssue(issue);
+
+ expect(Store.selectedCount()).toBe(0);
+ });
+
+ it('returns selected issue index if present', () => {
+ Store.toggleIssue(issue);
+
+ expect(Store.selectedIssueIndex(issue)).toBe(0);
+ });
+
+ it('returns -1 if issue is not selected', () => {
+ expect(Store.selectedIssueIndex(issue)).toBe(-1);
+ });
+
+ it('finds the selected issue', () => {
+ Store.toggleIssue(issue);
+
+ expect(Store.findSelectedIssue(issue)).toBe(issue);
+ });
+
+ it('does not find a selected issue', () => {
+ expect(Store.findSelectedIssue(issue)).toBe(undefined);
+ });
+
+ it('does not remove from selected issue if tab is not all', () => {
+ Store.store.activeTab = 'selected';
+
+ Store.toggleIssue(issue);
+ Store.toggleIssue(issue);
+
+ expect(Store.store.selectedIssues.length).toBe(1);
+ expect(Store.selectedCount()).toBe(0);
+ });
+
+ it('gets selected issue array with only selected issues', () => {
+ Store.toggleIssue(issue);
+ Store.toggleIssue(issue2);
+ Store.toggleIssue(issue2);
+
+ expect(Store.getSelectedIssues().length).toBe(1);
+ });
+});
diff --git a/spec/frontend/cycle_analytics/limit_warning_component_spec.js b/spec/frontend/cycle_analytics/limit_warning_component_spec.js
new file mode 100644
index 00000000000..13e9fe00a00
--- /dev/null
+++ b/spec/frontend/cycle_analytics/limit_warning_component_spec.js
@@ -0,0 +1,42 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import limitWarningComp from '~/cycle_analytics/components/limit_warning_component.vue';
+
+Vue.use(Translate);
+
+describe('Limit warning component', () => {
+ let component;
+ let LimitWarningComponent;
+
+ beforeEach(() => {
+ LimitWarningComponent = Vue.extend(limitWarningComp);
+ });
+
+ it('should not render if count is not exactly than 50', () => {
+ component = new LimitWarningComponent({
+ propsData: {
+ count: 5,
+ },
+ }).$mount();
+
+ expect(component.$el.textContent.trim()).toBe('');
+
+ component = new LimitWarningComponent({
+ propsData: {
+ count: 55,
+ },
+ }).$mount();
+
+ expect(component.$el.textContent.trim()).toBe('');
+ });
+
+ it('should render if count is exactly 50', () => {
+ component = new LimitWarningComponent({
+ propsData: {
+ count: 50,
+ },
+ }).$mount();
+
+ expect(component.$el.textContent.trim()).toBe('Showing 50 events');
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js
new file mode 100644
index 00000000000..984b3026209
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_stats_spec.js
@@ -0,0 +1,33 @@
+import { shallowMount } from '@vue/test-utils';
+import DiffStats from '~/diffs/components/diff_stats.vue';
+
+describe('diff_stats', () => {
+ it('does not render a group if diffFileLengths is not passed in', () => {
+ const wrapper = shallowMount(DiffStats, {
+ propsData: {
+ addedLines: 1,
+ removedLines: 2,
+ },
+ });
+ const groups = wrapper.findAll('.diff-stats-group');
+
+ expect(groups.length).toBe(2);
+ });
+
+ it('shows amount of files changed, lines added and lines removed when passed all props', () => {
+ const wrapper = shallowMount(DiffStats, {
+ propsData: {
+ addedLines: 100,
+ removedLines: 200,
+ diffFilesLength: 300,
+ },
+ });
+ const additions = wrapper.find('icon-stub[name="file-addition"]').element.parentNode;
+ const deletions = wrapper.find('icon-stub[name="file-deletion"]').element.parentNode;
+ const filesChanged = wrapper.find('icon-stub[name="doc-code"]').element.parentNode;
+
+ expect(additions.textContent).toContain('100');
+ expect(deletions.textContent).toContain('200');
+ expect(filesChanged.textContent).toContain('300');
+ });
+});
diff --git a/spec/frontend/diffs/components/edit_button_spec.js b/spec/frontend/diffs/components/edit_button_spec.js
new file mode 100644
index 00000000000..ccdae4cb312
--- /dev/null
+++ b/spec/frontend/diffs/components/edit_button_spec.js
@@ -0,0 +1,61 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import EditButton from '~/diffs/components/edit_button.vue';
+
+const localVue = createLocalVue();
+const editPath = 'test-path';
+
+describe('EditButton', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(EditButton, {
+ localVue,
+ sync: false,
+ propsData: { ...props },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('has correct href attribute', () => {
+ createComponent({
+ editPath,
+ canCurrentUserFork: false,
+ });
+
+ expect(wrapper.attributes('href')).toBe(editPath);
+ });
+
+ it('emits a show fork message event if current user can fork', () => {
+ createComponent({
+ editPath,
+ canCurrentUserFork: true,
+ });
+ wrapper.trigger('click');
+
+ expect(wrapper.emitted('showForkMessage')).toBeTruthy();
+ });
+
+ it('doesnt emit a show fork message event if current user cannot fork', () => {
+ createComponent({
+ editPath,
+ canCurrentUserFork: false,
+ });
+ wrapper.trigger('click');
+
+ expect(wrapper.emitted('showForkMessage')).toBeFalsy();
+ });
+
+ it('doesnt emit a show fork message event if current user can modify blob', () => {
+ createComponent({
+ editPath,
+ canCurrentUserFork: true,
+ canModifyBlob: true,
+ });
+ wrapper.trigger('click');
+
+ expect(wrapper.emitted('showForkMessage')).toBeFalsy();
+ });
+});
diff --git a/spec/frontend/diffs/components/hidden_files_warning_spec.js b/spec/frontend/diffs/components/hidden_files_warning_spec.js
new file mode 100644
index 00000000000..5bf5ddd27bd
--- /dev/null
+++ b/spec/frontend/diffs/components/hidden_files_warning_spec.js
@@ -0,0 +1,48 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue';
+
+const localVue = createLocalVue();
+const propsData = {
+ total: '10',
+ visible: 5,
+ plainDiffPath: 'plain-diff-path',
+ emailPatchPath: 'email-patch-path',
+};
+
+describe('HiddenFilesWarning', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(HiddenFilesWarning, {
+ localVue,
+ sync: false,
+ propsData,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('has a correct plain diff URL', () => {
+ const plainDiffLink = wrapper.findAll('a').wrappers.filter(x => x.text() === 'Plain diff')[0];
+
+ expect(plainDiffLink.attributes('href')).toBe(propsData.plainDiffPath);
+ });
+
+ it('has a correct email patch URL', () => {
+ const emailPatchLink = wrapper.findAll('a').wrappers.filter(x => x.text() === 'Email patch')[0];
+
+ expect(emailPatchLink.attributes('href')).toBe(propsData.emailPatchPath);
+ });
+
+ it('has a correct visible/total files text', () => {
+ const filesText = wrapper.find('strong');
+
+ expect(filesText.text()).toBe('5 of 10');
+ });
+});
diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js
new file mode 100644
index 00000000000..e45d34bf9d5
--- /dev/null
+++ b/spec/frontend/diffs/components/no_changes_spec.js
@@ -0,0 +1,40 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { createStore } from '~/mr_notes/stores';
+import NoChanges from '~/diffs/components/no_changes.vue';
+
+describe('Diff no changes empty state', () => {
+ let vm;
+
+ function createComponent(extendStore = () => {}) {
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ const store = createStore();
+ extendStore(store);
+
+ vm = shallowMount(localVue.extend(NoChanges), {
+ localVue,
+ store,
+ propsData: {
+ changesEmptyStateIllustration: '',
+ },
+ });
+ }
+
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it('prevents XSS', () => {
+ createComponent(store => {
+ // eslint-disable-next-line no-param-reassign
+ store.state.notes.noteableData = {
+ source_branch: '<script>alert("test");</script>',
+ target_branch: '<script>alert("test");</script>',
+ };
+ });
+
+ expect(vm.contains('script')).toBe(false);
+ });
+});
diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js
new file mode 100644
index 00000000000..1067a53906a
--- /dev/null
+++ b/spec/frontend/environment.js
@@ -0,0 +1,33 @@
+/* eslint-disable import/no-commonjs */
+
+const { ErrorWithStack } = require('jest-util');
+const JSDOMEnvironment = require('jest-environment-jsdom');
+
+class CustomEnvironment extends JSDOMEnvironment {
+ constructor(config, context) {
+ super(config, context);
+
+ Object.assign(context.console, {
+ error(...args) {
+ throw new ErrorWithStack(
+ `Unexpected call of console.error() with:\n\n${args.join(', ')}`,
+ this.error,
+ );
+ },
+
+ warn(...args) {
+ throw new ErrorWithStack(
+ `Unexpected call of console.warn() with:\n\n${args.join(', ')}`,
+ this.warn,
+ );
+ },
+ });
+
+ const { testEnvironmentOptions } = config;
+ this.global.gon = {
+ ee: testEnvironmentOptions.IS_EE,
+ };
+ }
+}
+
+module.exports = CustomEnvironment;
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
new file mode 100644
index 00000000000..503af3920a8
--- /dev/null
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -0,0 +1,118 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlTable, GlLink } from '@gitlab/ui';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ErrorTrackingList', () => {
+ let store;
+ let wrapper;
+ let actions;
+
+ function mountComponent({ errorTrackingEnabled = true } = {}) {
+ wrapper = shallowMount(ErrorTrackingList, {
+ localVue,
+ store,
+ propsData: {
+ indexPath: '/path',
+ enableErrorTrackingLink: '/link',
+ errorTrackingEnabled,
+ illustrationPath: 'illustration/path',
+ },
+ stubs: {
+ 'gl-link': GlLink,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ actions = {
+ getErrorList: () => {},
+ startPolling: () => {},
+ restartPolling: jasmine.createSpy('restartPolling'),
+ };
+
+ const state = {
+ errors: [],
+ loading: true,
+ };
+
+ store = new Vuex.Store({
+ actions,
+ state,
+ });
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('loading', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('shows spinner', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeTruthy();
+ expect(wrapper.find(GlTable).exists()).toBeFalsy();
+ expect(wrapper.find(GlButton).exists()).toBeFalsy();
+ });
+ });
+
+ describe('results', () => {
+ beforeEach(() => {
+ store.state.loading = false;
+
+ mountComponent();
+ });
+
+ it('shows table', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy();
+ expect(wrapper.find(GlTable).exists()).toBeTruthy();
+ expect(wrapper.find(GlButton).exists()).toBeTruthy();
+ });
+ });
+
+ describe('no results', () => {
+ beforeEach(() => {
+ store.state.loading = false;
+
+ mountComponent();
+ });
+
+ it('shows empty table', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy();
+ expect(wrapper.find(GlTable).exists()).toBeTruthy();
+ expect(wrapper.find(GlButton).exists()).toBeTruthy();
+ });
+
+ it('shows a message prompting to refresh', () => {
+ const refreshLink = wrapper.vm.$refs.empty.querySelector('a');
+
+ expect(refreshLink.textContent.trim()).toContain('Check again');
+ });
+
+ it('restarts polling', () => {
+ wrapper.find('.js-try-again').trigger('click');
+
+ expect(actions.restartPolling).toHaveBeenCalled();
+ });
+ });
+
+ describe('error tracking feature disabled', () => {
+ beforeEach(() => {
+ mountComponent({ errorTrackingEnabled: false });
+ });
+
+ it('shows empty state', () => {
+ expect(wrapper.find(GlEmptyState).exists()).toBeTruthy();
+ expect(wrapper.find(GlLoadingIcon).exists()).toBeFalsy();
+ expect(wrapper.find(GlTable).exists()).toBeFalsy();
+ expect(wrapper.find(GlButton).exists()).toBeFalsy();
+ });
+ });
+});
diff --git a/spec/frontend/error_tracking/store/mutation_spec.js b/spec/frontend/error_tracking/store/mutation_spec.js
new file mode 100644
index 00000000000..8117104bdbc
--- /dev/null
+++ b/spec/frontend/error_tracking/store/mutation_spec.js
@@ -0,0 +1,36 @@
+import mutations from '~/error_tracking/store/mutations';
+import * as types from '~/error_tracking/store/mutation_types';
+
+describe('Error tracking mutations', () => {
+ describe('SET_ERRORS', () => {
+ let state;
+
+ beforeEach(() => {
+ state = { errors: [] };
+ });
+
+ it('camelizes response', () => {
+ const errors = [
+ {
+ title: 'the title',
+ external_url: 'localhost:3456',
+ count: 100,
+ userCount: 10,
+ },
+ ];
+
+ mutations[types.SET_ERRORS](state, errors);
+
+ expect(state).toEqual({
+ errors: [
+ {
+ title: 'the title',
+ externalUrl: 'localhost:3456',
+ count: 100,
+ userCount: 10,
+ },
+ ],
+ });
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/filtered_search_token_keys_spec.js b/spec/frontend/filtered_search/filtered_search_token_keys_spec.js
new file mode 100644
index 00000000000..d1fea18dea8
--- /dev/null
+++ b/spec/frontend/filtered_search/filtered_search_token_keys_spec.js
@@ -0,0 +1,141 @@
+import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
+
+describe('Filtered Search Token Keys', () => {
+ const tokenKeys = [
+ {
+ key: 'author',
+ type: 'string',
+ param: 'username',
+ symbol: '@',
+ icon: 'pencil',
+ tag: '@author',
+ },
+ ];
+
+ const conditions = [
+ {
+ url: 'assignee_id=0',
+ tokenKey: 'assignee',
+ value: 'none',
+ },
+ ];
+
+ describe('get', () => {
+ it('should return tokenKeys', () => {
+ expect(new FilteredSearchTokenKeys().get()).not.toBeNull();
+ });
+
+ it('should return tokenKeys as an array', () => {
+ expect(new FilteredSearchTokenKeys().get() instanceof Array).toBe(true);
+ });
+ });
+
+ describe('getKeys', () => {
+ it('should return keys', () => {
+ const getKeys = new FilteredSearchTokenKeys(tokenKeys).getKeys();
+ const keys = new FilteredSearchTokenKeys(tokenKeys).get().map(i => i.key);
+
+ keys.forEach((key, i) => {
+ expect(key).toEqual(getKeys[i]);
+ });
+ });
+ });
+
+ describe('getConditions', () => {
+ it('should return conditions', () => {
+ expect(new FilteredSearchTokenKeys().getConditions()).not.toBeNull();
+ });
+
+ it('should return conditions as an array', () => {
+ expect(new FilteredSearchTokenKeys().getConditions() instanceof Array).toBe(true);
+ });
+ });
+
+ describe('searchByKey', () => {
+ it('should return null when key not found', () => {
+ const tokenKey = new FilteredSearchTokenKeys(tokenKeys).searchByKey('notakey');
+
+ expect(tokenKey).toBeNull();
+ });
+
+ it('should return tokenKey when found by key', () => {
+ const result = new FilteredSearchTokenKeys(tokenKeys).searchByKey(tokenKeys[0].key);
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchBySymbol', () => {
+ it('should return null when symbol not found', () => {
+ const tokenKey = new FilteredSearchTokenKeys(tokenKeys).searchBySymbol('notasymbol');
+
+ expect(tokenKey).toBeNull();
+ });
+
+ it('should return tokenKey when found by symbol', () => {
+ const result = new FilteredSearchTokenKeys(tokenKeys).searchBySymbol(tokenKeys[0].symbol);
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchByKeyParam', () => {
+ it('should return null when key param not found', () => {
+ const tokenKey = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam('notakeyparam');
+
+ expect(tokenKey).toBeNull();
+ });
+
+ it('should return tokenKey when found by key param', () => {
+ const result = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam(
+ `${tokenKeys[0].key}_${tokenKeys[0].param}`,
+ );
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+
+ it('should return alternative tokenKey when found by key param', () => {
+ const result = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam(
+ `${tokenKeys[0].key}_${tokenKeys[0].param}`,
+ );
+
+ expect(result).toEqual(tokenKeys[0]);
+ });
+ });
+
+ describe('searchByConditionUrl', () => {
+ it('should return null when condition url not found', () => {
+ const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionUrl(null);
+
+ expect(condition).toBeNull();
+ });
+
+ it('should return condition when found by url', () => {
+ const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionUrl(
+ conditions[0].url,
+ );
+
+ expect(result).toBe(conditions[0]);
+ });
+ });
+
+ describe('searchByConditionKeyValue', () => {
+ it('should return null when condition tokenKey and value not found', () => {
+ const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
+ null,
+ null,
+ );
+
+ expect(condition).toBeNull();
+ });
+
+ it('should return condition when found by tokenKey and value', () => {
+ const result = new FilteredSearchTokenKeys([], [], conditions).searchByConditionKeyValue(
+ conditions[0].tokenKey,
+ conditions[0].value,
+ );
+
+ expect(result).toEqual(conditions[0]);
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/services/recent_searches_service_error_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_error_spec.js
new file mode 100644
index 00000000000..ea7c146fa4f
--- /dev/null
+++ b/spec/frontend/filtered_search/services/recent_searches_service_error_spec.js
@@ -0,0 +1,18 @@
+import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
+
+describe('RecentSearchesServiceError', () => {
+ let recentSearchesServiceError;
+
+ beforeEach(() => {
+ recentSearchesServiceError = new RecentSearchesServiceError();
+ });
+
+ it('instantiates an instance of RecentSearchesServiceError and not an Error', () => {
+ expect(recentSearchesServiceError).toEqual(jasmine.any(RecentSearchesServiceError));
+ expect(recentSearchesServiceError.name).toBe('RecentSearchesServiceError');
+ });
+
+ it('should set a default message', () => {
+ expect(recentSearchesServiceError.message).toBe('Recent Searches Service is unavailable');
+ });
+});
diff --git a/spec/frontend/filtered_search/stores/recent_searches_store_spec.js b/spec/frontend/filtered_search/stores/recent_searches_store_spec.js
new file mode 100644
index 00000000000..56bb82ae941
--- /dev/null
+++ b/spec/frontend/filtered_search/stores/recent_searches_store_spec.js
@@ -0,0 +1,53 @@
+import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store';
+
+describe('RecentSearchesStore', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new RecentSearchesStore();
+ });
+
+ describe('addRecentSearch', () => {
+ it('should add to the front of the list', () => {
+ store.addRecentSearch('foo');
+ store.addRecentSearch('bar');
+
+ expect(store.state.recentSearches).toEqual(['bar', 'foo']);
+ });
+
+ it('should deduplicate', () => {
+ store.addRecentSearch('foo');
+ store.addRecentSearch('bar');
+ store.addRecentSearch('foo');
+
+ expect(store.state.recentSearches).toEqual(['foo', 'bar']);
+ });
+
+ it('only keeps track of 5 items', () => {
+ store.addRecentSearch('1');
+ store.addRecentSearch('2');
+ store.addRecentSearch('3');
+ store.addRecentSearch('4');
+ store.addRecentSearch('5');
+ store.addRecentSearch('6');
+ store.addRecentSearch('7');
+
+ expect(store.state.recentSearches).toEqual(['7', '6', '5', '4', '3']);
+ });
+ });
+
+ describe('setRecentSearches', () => {
+ it('should override list', () => {
+ store.setRecentSearches(['foo', 'bar']);
+ store.setRecentSearches(['baz', 'qux']);
+
+ expect(store.state.recentSearches).toEqual(['baz', 'qux']);
+ });
+
+ it('only keeps track of 5 items', () => {
+ store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']);
+
+ expect(store.state.recentSearches).toEqual(['1', '2', '3', '4', '5']);
+ });
+ });
+});
diff --git a/spec/frontend/frequent_items/store/getters_spec.js b/spec/frontend/frequent_items/store/getters_spec.js
new file mode 100644
index 00000000000..1cd12eb6832
--- /dev/null
+++ b/spec/frontend/frequent_items/store/getters_spec.js
@@ -0,0 +1,24 @@
+import state from '~/frequent_items/store/state';
+import * as getters from '~/frequent_items/store/getters';
+
+describe('Frequent Items Dropdown Store Getters', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('hasSearchQuery', () => {
+ it('should return `true` when search query is present', () => {
+ mockedState.searchQuery = 'test';
+
+ expect(getters.hasSearchQuery(mockedState)).toBe(true);
+ });
+
+ it('should return `false` when search query is empty', () => {
+ mockedState.searchQuery = '';
+
+ expect(getters.hasSearchQuery(mockedState)).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 27c679c749b..ed12af925f1 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -6,17 +6,26 @@ import GfmAutoComplete from '~/gfm_auto_complete';
import 'jquery.caret';
import 'at.js';
+import { TEST_HOST } from 'helpers/test_constants';
+import { setTestTimeout } from 'helpers/timeout';
+import { getJSONFixture } from 'helpers/fixtures';
+
+setTestTimeout(500);
+
+const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
+
describe('GfmAutoComplete', () => {
const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
fetchData: () => {},
});
let atwhoInstance;
- let items;
let sorterValue;
describe('DefaultOptions.sorter', () => {
describe('assets loading', () => {
+ let items;
+
beforeEach(() => {
jest.spyOn(GfmAutoComplete, 'isLoading').mockReturnValue(true);
@@ -61,7 +70,7 @@ describe('GfmAutoComplete', () => {
atwhoInstance = { setting: {} };
const query = 'query';
- items = [];
+ const items = [];
const searchKey = 'searchKey';
gfmAutoCompleteCallbacks.sorter.call(atwhoInstance, query, items, searchKey);
@@ -250,4 +259,90 @@ describe('GfmAutoComplete', () => {
).toBe('<li><small>grp/proj#5</small> Some Issue</li>');
});
});
+
+ describe('labels', () => {
+ const dataSources = {
+ labels: `${TEST_HOST}/autocomplete_sources/labels`,
+ };
+
+ const allLabels = labelsFixture;
+ const assignedLabels = allLabels.filter(label => label.set);
+ const unassignedLabels = allLabels.filter(label => !label.set);
+
+ let autocomplete;
+ let $textarea;
+
+ beforeEach(() => {
+ autocomplete = new GfmAutoComplete(dataSources);
+ $textarea = $('<textarea></textarea>');
+ autocomplete.setup($textarea, { labels: true });
+ });
+
+ afterEach(() => {
+ autocomplete.destroy();
+ });
+
+ const triggerDropdown = text => {
+ $textarea
+ .trigger('focus')
+ .val(text)
+ .caret('pos', -1);
+ $textarea.trigger('keyup');
+
+ return new Promise(window.requestAnimationFrame);
+ };
+
+ const getDropdownItems = () => {
+ const dropdown = document.getElementById('at-view-labels');
+ const items = dropdown.getElementsByTagName('li');
+ return [].map.call(items, item => item.textContent.trim());
+ };
+
+ const expectLabels = ({ input, output }) =>
+ triggerDropdown(input).then(() => {
+ expect(getDropdownItems()).toEqual(output.map(label => label.title));
+ });
+
+ describe('with no labels assigned', () => {
+ beforeEach(() => {
+ autocomplete.cachedData['~'] = [...unassignedLabels];
+ });
+
+ it.each`
+ input | output
+ ${'~'} | ${unassignedLabels}
+ ${'/label ~'} | ${unassignedLabels}
+ ${'/relabel ~'} | ${unassignedLabels}
+ ${'/unlabel ~'} | ${[]}
+ `('$input shows $output.length labels', expectLabels);
+ });
+
+ describe('with some labels assigned', () => {
+ beforeEach(() => {
+ autocomplete.cachedData['~'] = allLabels;
+ });
+
+ it.each`
+ input | output
+ ${'~'} | ${allLabels}
+ ${'/label ~'} | ${unassignedLabels}
+ ${'/relabel ~'} | ${allLabels}
+ ${'/unlabel ~'} | ${assignedLabels}
+ `('$input shows $output.length labels', expectLabels);
+ });
+
+ describe('with all labels assigned', () => {
+ beforeEach(() => {
+ autocomplete.cachedData['~'] = [...assignedLabels];
+ });
+
+ it.each`
+ input | output
+ ${'~'} | ${assignedLabels}
+ ${'/label ~'} | ${[]}
+ ${'/relabel ~'} | ${assignedLabels}
+ ${'/unlabel ~'} | ${assignedLabels}
+ `('$input shows $output.length labels', expectLabels);
+ });
+ });
});
diff --git a/spec/frontend/helpers/class_spec_helper.js b/spec/frontend/helpers/class_spec_helper.js
new file mode 100644
index 00000000000..7a60d33b471
--- /dev/null
+++ b/spec/frontend/helpers/class_spec_helper.js
@@ -0,0 +1,9 @@
+export default class ClassSpecHelper {
+ static itShouldBeAStaticMethod(base, method) {
+ return it('should be a static method', () => {
+ expect(Object.prototype.hasOwnProperty.call(base, method)).toBeTruthy();
+ });
+ }
+}
+
+window.ClassSpecHelper = ClassSpecHelper;
diff --git a/spec/frontend/helpers/fixtures.js b/spec/frontend/helpers/fixtures.js
new file mode 100644
index 00000000000..f0351aa31c6
--- /dev/null
+++ b/spec/frontend/helpers/fixtures.js
@@ -0,0 +1,36 @@
+import fs from 'fs';
+import path from 'path';
+
+import { ErrorWithStack } from 'jest-util';
+
+const fixturesBasePath = path.join(process.cwd(), 'spec', 'javascripts', 'fixtures');
+
+export function getFixture(relativePath) {
+ const absolutePath = path.join(fixturesBasePath, relativePath);
+ if (!fs.existsSync(absolutePath)) {
+ throw new ErrorWithStack(
+ `Fixture file ${relativePath} does not exist.
+
+Did you run bin/rake karma:fixtures?`,
+ getFixture,
+ );
+ }
+
+ return fs.readFileSync(absolutePath, 'utf8');
+}
+
+export const getJSONFixture = relativePath => JSON.parse(getFixture(relativePath));
+
+export const resetHTMLFixture = () => {
+ document.body.textContent = '';
+};
+
+export const setHTMLFixture = (htmlContent, resetHook = afterEach) => {
+ document.body.outerHTML = htmlContent;
+ resetHook(resetHTMLFixture);
+};
+
+export const loadHTMLFixture = (relativePath, resetHook = afterEach) => {
+ const fileContent = getFixture(relativePath);
+ setHTMLFixture(fileContent, resetHook);
+};
diff --git a/spec/frontend/helpers/locale_helper.js b/spec/frontend/helpers/locale_helper.js
new file mode 100644
index 00000000000..80047b06003
--- /dev/null
+++ b/spec/frontend/helpers/locale_helper.js
@@ -0,0 +1,11 @@
+/* eslint-disable import/prefer-default-export */
+
+export const setLanguage = languageCode => {
+ const htmlElement = document.querySelector('html');
+
+ if (languageCode) {
+ htmlElement.setAttribute('lang', languageCode);
+ } else {
+ htmlElement.removeAttribute('lang');
+ }
+};
diff --git a/spec/frontend/helpers/scroll_into_view_promise.js b/spec/frontend/helpers/scroll_into_view_promise.js
new file mode 100644
index 00000000000..0edea2103da
--- /dev/null
+++ b/spec/frontend/helpers/scroll_into_view_promise.js
@@ -0,0 +1,28 @@
+export default function scrollIntoViewPromise(intersectionTarget, timeout = 100, maxTries = 5) {
+ return new Promise((resolve, reject) => {
+ let intersectionObserver;
+ let retry = 0;
+
+ const intervalId = setInterval(() => {
+ if (retry >= maxTries) {
+ intersectionObserver.disconnect();
+ clearInterval(intervalId);
+ reject(new Error(`Could not scroll target into viewPort within ${timeout * maxTries} ms`));
+ }
+ retry += 1;
+ intersectionTarget.scrollIntoView();
+ }, timeout);
+
+ intersectionObserver = new IntersectionObserver(entries => {
+ if (entries[0].isIntersecting) {
+ intersectionObserver.disconnect();
+ clearInterval(intervalId);
+ resolve();
+ }
+ });
+
+ intersectionObserver.observe(intersectionTarget);
+
+ intersectionTarget.scrollIntoView();
+ });
+}
diff --git a/spec/frontend/helpers/set_timeout_promise_helper.js b/spec/frontend/helpers/set_timeout_promise_helper.js
new file mode 100644
index 00000000000..47087619187
--- /dev/null
+++ b/spec/frontend/helpers/set_timeout_promise_helper.js
@@ -0,0 +1,4 @@
+export default (time = 0) =>
+ new Promise(resolve => {
+ setTimeout(resolve, time);
+ });
diff --git a/spec/frontend/helpers/user_mock_data_helper.js b/spec/frontend/helpers/user_mock_data_helper.js
new file mode 100644
index 00000000000..6999fa1f8a1
--- /dev/null
+++ b/spec/frontend/helpers/user_mock_data_helper.js
@@ -0,0 +1,14 @@
+export default {
+ createNumberRandomUsers(numberUsers) {
+ const users = [];
+ for (let i = 0; i < numberUsers; i += 1) {
+ users.push({
+ avatar: 'https://gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: i + 1,
+ name: `GitLab User ${i}`,
+ username: `gitlab${i}`,
+ });
+ }
+ return users;
+ },
+};
diff --git a/spec/frontend/helpers/vue_component_helper.js b/spec/frontend/helpers/vue_component_helper.js
new file mode 100644
index 00000000000..e0fe18e5560
--- /dev/null
+++ b/spec/frontend/helpers/vue_component_helper.js
@@ -0,0 +1,18 @@
+/**
+ * Replaces line break with an empty space
+ * @param {*} data
+ */
+export const removeBreakLine = data => data.replace(/\r?\n|\r/g, ' ');
+
+/**
+ * Removes line breaks, spaces and trims the given text
+ * @param {String} str
+ * @returns {String}
+ */
+export const trimText = str =>
+ str
+ .replace(/\r?\n|\r/g, '')
+ .replace(/\s\s+/g, ' ')
+ .trim();
+
+export const removeWhitespace = str => str.replace(/\s\s+/g, ' ');
diff --git a/spec/frontend/helpers/vue_mount_component_helper.js b/spec/frontend/helpers/vue_mount_component_helper.js
new file mode 100644
index 00000000000..6848c95d95d
--- /dev/null
+++ b/spec/frontend/helpers/vue_mount_component_helper.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+
+const mountComponent = (Component, props = {}, el = null) =>
+ new Component({
+ propsData: props,
+ }).$mount(el);
+
+export const createComponentWithStore = (Component, store, propsData = {}) =>
+ new Component({
+ store,
+ propsData,
+ });
+
+export const mountComponentWithStore = (Component, { el, props, store }) =>
+ new Component({
+ store,
+ propsData: props || {},
+ }).$mount(el);
+
+export const mountComponentWithSlots = (Component, { props, slots }) => {
+ const component = new Component({
+ propsData: props || {},
+ });
+
+ component.$slots = slots;
+
+ return component.$mount();
+};
+
+/**
+ * Mount a component with the given render method.
+ *
+ * This helps with inserting slots that need to be compiled.
+ */
+export const mountComponentWithRender = (render, el = null) =>
+ mountComponent(Vue.extend({ render }), {}, el);
+
+export default mountComponent;
diff --git a/spec/frontend/helpers/vue_resource_helper.js b/spec/frontend/helpers/vue_resource_helper.js
new file mode 100644
index 00000000000..0f58af09933
--- /dev/null
+++ b/spec/frontend/helpers/vue_resource_helper.js
@@ -0,0 +1,11 @@
+// eslint-disable-next-line import/prefer-default-export
+export const headersInterceptor = (request, next) => {
+ next(response => {
+ const headers = {};
+ response.headers.forEach((value, key) => {
+ headers[key] = value;
+ });
+ // eslint-disable-next-line no-param-reassign
+ response.headers = headers;
+ });
+};
diff --git a/spec/frontend/helpers/vue_test_utils_helper.js b/spec/frontend/helpers/vue_test_utils_helper.js
new file mode 100644
index 00000000000..19e27388eeb
--- /dev/null
+++ b/spec/frontend/helpers/vue_test_utils_helper.js
@@ -0,0 +1,19 @@
+/* eslint-disable import/prefer-default-export */
+
+const vNodeContainsText = (vnode, text) =>
+ (vnode.text && vnode.text.includes(text)) ||
+ (vnode.children && vnode.children.filter(child => vNodeContainsText(child, text)).length);
+
+/**
+ * Determines whether a `shallowMount` Wrapper contains text
+ * within one of it's slots. This will also work on Wrappers
+ * acquired with `find()`, but only if it's parent Wrapper
+ * was shallowMounted.
+ * NOTE: Prefer checking the rendered output of a component
+ * wherever possible using something like `text()` instead.
+ * @param {Wrapper} shallowWrapper - Vue test utils wrapper (shallowMounted)
+ * @param {String} slotName
+ * @param {String} text
+ */
+export const shallowWrapperContainsSlotText = (shallowWrapper, slotName, text) =>
+ !!shallowWrapper.vm.$slots[slotName].filter(vnode => vNodeContainsText(vnode, text)).length;
diff --git a/spec/frontend/helpers/vuex_action_helper.js b/spec/frontend/helpers/vuex_action_helper.js
new file mode 100644
index 00000000000..88652202a8e
--- /dev/null
+++ b/spec/frontend/helpers/vuex_action_helper.js
@@ -0,0 +1,104 @@
+const noop = () => {};
+
+/**
+ * Helper for testing action with expected mutations inspired in
+ * https://vuex.vuejs.org/en/testing.html
+ *
+ * @param {Function} action to be tested
+ * @param {Object} payload will be provided to the action
+ * @param {Object} state will be provided to the action
+ * @param {Array} [expectedMutations=[]] mutations expected to be committed
+ * @param {Array} [expectedActions=[]] actions expected to be dispatched
+ * @param {Function} [done=noop] to be executed after the tests
+ * @return {Promise}
+ *
+ * @example
+ * testAction(
+ * actions.actionName, // action
+ * { }, // mocked payload
+ * state, //state
+ * // expected mutations
+ * [
+ * { type: types.MUTATION}
+ * { type: types.MUTATION_1, payload: jasmine.any(Number)}
+ * ],
+ * // expected actions
+ * [
+ * { type: 'actionName', payload: {param: 'foobar'}},
+ * { type: 'actionName1'}
+ * ]
+ * done,
+ * );
+ *
+ * @example
+ * testAction(
+ * actions.actionName, // action
+ * { }, // mocked payload
+ * state, //state
+ * [ { type: types.MUTATION} ], // expected mutations
+ * [], // expected actions
+ * ).then(done)
+ * .catch(done.fail);
+ */
+export default (
+ action,
+ payload,
+ state,
+ expectedMutations = [],
+ expectedActions = [],
+ done = noop,
+) => {
+ const mutations = [];
+ const actions = [];
+
+ // mock commit
+ const commit = (type, mutationPayload) => {
+ const mutation = { type };
+
+ if (typeof mutationPayload !== 'undefined') {
+ mutation.payload = mutationPayload;
+ }
+
+ mutations.push(mutation);
+ };
+
+ // mock dispatch
+ const dispatch = (type, actionPayload) => {
+ const dispatchedAction = { type };
+
+ if (typeof actionPayload !== 'undefined') {
+ dispatchedAction.payload = actionPayload;
+ }
+
+ actions.push(dispatchedAction);
+ };
+
+ const validateResults = () => {
+ expect({
+ mutations,
+ actions,
+ }).toEqual({
+ mutations: expectedMutations,
+ actions: expectedActions,
+ });
+ done();
+ };
+
+ const result = action(
+ { commit, state, dispatch, rootState: state, rootGetters: state, getters: state },
+ payload,
+ );
+
+ return new Promise(resolve => {
+ setImmediate(resolve);
+ })
+ .then(() => result)
+ .catch(error => {
+ validateResults();
+ throw error;
+ })
+ .then(data => {
+ validateResults();
+ return data;
+ });
+};
diff --git a/spec/frontend/helpers/wait_for_attribute_change.js b/spec/frontend/helpers/wait_for_attribute_change.js
new file mode 100644
index 00000000000..8f22d569222
--- /dev/null
+++ b/spec/frontend/helpers/wait_for_attribute_change.js
@@ -0,0 +1,16 @@
+export default (domElement, attributes, timeout = 1500) =>
+ new Promise((resolve, reject) => {
+ let observer;
+ const timeoutId = setTimeout(() => {
+ observer.disconnect();
+ reject(new Error(`Could not see an attribute update within ${timeout} ms`));
+ }, timeout);
+
+ observer = new MutationObserver(() => {
+ clearTimeout(timeoutId);
+ observer.disconnect();
+ resolve();
+ });
+
+ observer.observe(domElement, { attributes: true, attributeFilter: attributes });
+ });
diff --git a/spec/frontend/helpers/wait_for_promises.js b/spec/frontend/helpers/wait_for_promises.js
new file mode 100644
index 00000000000..1d2b53fc770
--- /dev/null
+++ b/spec/frontend/helpers/wait_for_promises.js
@@ -0,0 +1 @@
+export default () => new Promise(resolve => requestAnimationFrame(resolve));
diff --git a/spec/frontend/ide/lib/common/disposable_spec.js b/spec/frontend/ide/lib/common/disposable_spec.js
new file mode 100644
index 00000000000..af12ca15369
--- /dev/null
+++ b/spec/frontend/ide/lib/common/disposable_spec.js
@@ -0,0 +1,44 @@
+import Disposable from '~/ide/lib/common/disposable';
+
+describe('Multi-file editor library disposable class', () => {
+ let instance;
+ let disposableClass;
+
+ beforeEach(() => {
+ instance = new Disposable();
+
+ disposableClass = {
+ dispose: jasmine.createSpy('dispose'),
+ };
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('add', () => {
+ it('adds disposable classes', () => {
+ instance.add(disposableClass);
+
+ expect(instance.disposers.size).toBe(1);
+ });
+ });
+
+ describe('dispose', () => {
+ beforeEach(() => {
+ instance.add(disposableClass);
+ });
+
+ it('calls dispose on all cached disposers', () => {
+ instance.dispose();
+
+ expect(disposableClass.dispose).toHaveBeenCalled();
+ });
+
+ it('clears cached disposers', () => {
+ instance.dispose();
+
+ expect(instance.disposers.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/diff/diff_spec.js b/spec/frontend/ide/lib/diff/diff_spec.js
new file mode 100644
index 00000000000..57f3ac3d365
--- /dev/null
+++ b/spec/frontend/ide/lib/diff/diff_spec.js
@@ -0,0 +1,80 @@
+import { computeDiff } from '~/ide/lib/diff/diff';
+
+describe('Multi-file editor library diff calculator', () => {
+ describe('computeDiff', () => {
+ it('returns empty array if no changes', () => {
+ const diff = computeDiff('123', '123');
+
+ expect(diff).toEqual([]);
+ });
+
+ describe('modified', () => {
+ it('', () => {
+ const diff = computeDiff('123', '1234')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeTruthy();
+ expect(diff.removed).toBeUndefined();
+ });
+
+ it('', () => {
+ const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeTruthy();
+ expect(diff.removed).toBeUndefined();
+ expect(diff.lineNumber).toBe(2);
+ });
+ });
+
+ describe('added', () => {
+ it('', () => {
+ const diff = computeDiff('123', '123\n123')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeUndefined();
+ expect(diff.removed).toBeUndefined();
+ });
+
+ it('', () => {
+ const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0];
+
+ expect(diff.added).toBeTruthy();
+ expect(diff.modified).toBeUndefined();
+ expect(diff.removed).toBeUndefined();
+ expect(diff.lineNumber).toBe(3);
+ });
+ });
+
+ describe('removed', () => {
+ it('', () => {
+ const diff = computeDiff('123', '')[0];
+
+ expect(diff.added).toBeUndefined();
+ expect(diff.modified).toBeUndefined();
+ expect(diff.removed).toBeTruthy();
+ });
+
+ it('', () => {
+ const diff = computeDiff('123\n123\n123', '123\n123')[0];
+
+ expect(diff.added).toBeUndefined();
+ expect(diff.modified).toBeTruthy();
+ expect(diff.removed).toBeTruthy();
+ expect(diff.lineNumber).toBe(2);
+ });
+ });
+
+ it('includes line number of change', () => {
+ const diff = computeDiff('123', '')[0];
+
+ expect(diff.lineNumber).toBe(1);
+ });
+
+ it('includes end line number of change', () => {
+ const diff = computeDiff('123', '')[0];
+
+ expect(diff.endLineNumber).toBe(1);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/editor_options_spec.js b/spec/frontend/ide/lib/editor_options_spec.js
new file mode 100644
index 00000000000..d149a883166
--- /dev/null
+++ b/spec/frontend/ide/lib/editor_options_spec.js
@@ -0,0 +1,11 @@
+import editorOptions from '~/ide/lib/editor_options';
+
+describe('Multi-file editor library editor options', () => {
+ it('returns an array', () => {
+ expect(editorOptions).toEqual(jasmine.any(Array));
+ });
+
+ it('contains readOnly option', () => {
+ expect(editorOptions[0].readOnly).toBeDefined();
+ });
+});
diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js
new file mode 100644
index 00000000000..fe791aa2b74
--- /dev/null
+++ b/spec/frontend/ide/lib/files_spec.js
@@ -0,0 +1,77 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
+import { decorateFiles, splitParent } from '~/ide/lib/files';
+import { decorateData } from '~/ide/stores/utils';
+
+const TEST_BRANCH_ID = 'lorem-ipsum';
+const TEST_PROJECT_ID = 10;
+
+const createEntries = paths => {
+ const createEntry = (acc, { path, type, children }) => {
+ // Sometimes we need to end the url with a '/'
+ const createUrl = base => (type === 'tree' ? `${base}/` : base);
+
+ const { name, parent } = splitParent(path);
+ const parentEntry = acc[parent];
+
+ acc[path] = {
+ ...decorateData({
+ projectId: TEST_PROJECT_ID,
+ branchId: TEST_BRANCH_ID,
+ id: path,
+ name,
+ path,
+ url: createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}/-/${path}`),
+ type,
+ previewMode: viewerInformationForPath(path),
+ parentPath: parent,
+ parentTreeUrl: parentEntry
+ ? parentEntry.url
+ : createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}`),
+ }),
+ tree: children.map(childName => jasmine.objectContaining({ name: childName })),
+ };
+
+ return acc;
+ };
+
+ const entries = paths.reduce(createEntry, {});
+
+ // Wrap entries in jasmine.objectContaining.
+ // We couldn't do this earlier because we still need to select properties from parent entries.
+ return Object.keys(entries).reduce((acc, key) => {
+ acc[key] = jasmine.objectContaining(entries[key]);
+
+ return acc;
+ }, {});
+};
+
+describe('IDE lib decorate files', () => {
+ it('creates entries and treeList', () => {
+ const data = ['app/assets/apples/foo.js', 'app/bugs.js', 'README.md'];
+ const expectedEntries = createEntries([
+ { path: 'app', type: 'tree', children: ['assets', 'bugs.js'] },
+ { path: 'app/assets', type: 'tree', children: ['apples'] },
+ { path: 'app/assets/apples', type: 'tree', children: ['foo.js'] },
+ { path: 'app/assets/apples/foo.js', type: 'blob', children: [] },
+ { path: 'app/bugs.js', type: 'blob', children: [] },
+ { path: 'README.md', type: 'blob', children: [] },
+ ]);
+
+ const { entries, treeList } = decorateFiles({
+ data,
+ branchId: TEST_BRANCH_ID,
+ projectId: TEST_PROJECT_ID,
+ });
+
+ // Here we test the keys and then each key/value individually because `expect(entries).toEqual(expectedEntries)`
+ // was taking a very long time for some reason. Probably due to large objects and nested `jasmine.objectContaining`.
+ const entryKeys = Object.keys(entries);
+
+ expect(entryKeys).toEqual(Object.keys(expectedEntries));
+ entryKeys.forEach(key => {
+ expect(entries[key]).toEqual(expectedEntries[key]);
+ });
+
+ expect(treeList).toEqual([expectedEntries.app, expectedEntries['README.md']]);
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/commit/mutations_spec.js b/spec/frontend/ide/stores/modules/commit/mutations_spec.js
new file mode 100644
index 00000000000..5de7a281d34
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/commit/mutations_spec.js
@@ -0,0 +1,42 @@
+import commitState from '~/ide/stores/modules/commit/state';
+import mutations from '~/ide/stores/modules/commit/mutations';
+
+describe('IDE commit module mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = commitState();
+ });
+
+ describe('UPDATE_COMMIT_MESSAGE', () => {
+ it('updates commitMessage', () => {
+ mutations.UPDATE_COMMIT_MESSAGE(state, 'testing');
+
+ expect(state.commitMessage).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_COMMIT_ACTION', () => {
+ it('updates commitAction', () => {
+ mutations.UPDATE_COMMIT_ACTION(state, 'testing');
+
+ expect(state.commitAction).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_NEW_BRANCH_NAME', () => {
+ it('updates newBranchName', () => {
+ mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing');
+
+ expect(state.newBranchName).toBe('testing');
+ });
+ });
+
+ describe('UPDATE_LOADING', () => {
+ it('updates submitCommitLoading', () => {
+ mutations.UPDATE_LOADING(state, true);
+
+ expect(state.submitCommitLoading).toBeTruthy();
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js
new file mode 100644
index 00000000000..17cb457881f
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js
@@ -0,0 +1,59 @@
+import createState from '~/ide/stores/state';
+import { activityBarViews } from '~/ide/constants';
+import * as getters from '~/ide/stores/modules/file_templates/getters';
+
+describe('IDE file templates getters', () => {
+ describe('templateTypes', () => {
+ it('returns list of template types', () => {
+ expect(getters.templateTypes().length).toBe(4);
+ });
+ });
+
+ describe('showFileTemplatesBar', () => {
+ let rootState;
+
+ beforeEach(() => {
+ rootState = createState();
+ });
+
+ it('returns true if template is found and currentActivityView is edit', () => {
+ rootState.currentActivityView = activityBarViews.edit;
+
+ expect(
+ getters.showFileTemplatesBar(
+ null,
+ {
+ templateTypes: getters.templateTypes(),
+ },
+ rootState,
+ )('LICENSE'),
+ ).toBe(true);
+ });
+
+ it('returns false if template is found and currentActivityView is not edit', () => {
+ rootState.currentActivityView = activityBarViews.commit;
+
+ expect(
+ getters.showFileTemplatesBar(
+ null,
+ {
+ templateTypes: getters.templateTypes(),
+ },
+ rootState,
+ )('LICENSE'),
+ ).toBe(false);
+ });
+
+ it('returns undefined if not found', () => {
+ expect(
+ getters.showFileTemplatesBar(
+ null,
+ {
+ templateTypes: getters.templateTypes(),
+ },
+ rootState,
+ )('test'),
+ ).toBe(undefined);
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/file_templates/mutations_spec.js b/spec/frontend/ide/stores/modules/file_templates/mutations_spec.js
new file mode 100644
index 00000000000..8e0e3ae99a1
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/file_templates/mutations_spec.js
@@ -0,0 +1,69 @@
+import createState from '~/ide/stores/modules/file_templates/state';
+import * as types from '~/ide/stores/modules/file_templates/mutation_types';
+import mutations from '~/ide/stores/modules/file_templates/mutations';
+
+describe('IDE file templates mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState();
+ });
+
+ describe(types.REQUEST_TEMPLATE_TYPES, () => {
+ it('sets isLoading', () => {
+ mutations[types.REQUEST_TEMPLATE_TYPES](state);
+
+ expect(state.isLoading).toBe(true);
+ });
+ });
+
+ describe(types.RECEIVE_TEMPLATE_TYPES_ERROR, () => {
+ it('sets isLoading', () => {
+ state.isLoading = true;
+
+ mutations[types.RECEIVE_TEMPLATE_TYPES_ERROR](state);
+
+ expect(state.isLoading).toBe(false);
+ });
+ });
+
+ describe(types.RECEIVE_TEMPLATE_TYPES_SUCCESS, () => {
+ it('sets isLoading to false', () => {
+ state.isLoading = true;
+
+ mutations[types.RECEIVE_TEMPLATE_TYPES_SUCCESS](state, []);
+
+ expect(state.isLoading).toBe(false);
+ });
+
+ it('sets templates', () => {
+ mutations[types.RECEIVE_TEMPLATE_TYPES_SUCCESS](state, ['test']);
+
+ expect(state.templates).toEqual(['test']);
+ });
+ });
+
+ describe(types.SET_SELECTED_TEMPLATE_TYPE, () => {
+ it('sets selectedTemplateType', () => {
+ mutations[types.SET_SELECTED_TEMPLATE_TYPE](state, 'type');
+
+ expect(state.selectedTemplateType).toBe('type');
+ });
+
+ it('clears templates', () => {
+ state.templates = ['test'];
+
+ mutations[types.SET_SELECTED_TEMPLATE_TYPE](state, 'type');
+
+ expect(state.templates).toEqual([]);
+ });
+ });
+
+ describe(types.SET_UPDATE_SUCCESS, () => {
+ it('sets updateSuccess', () => {
+ mutations[types.SET_UPDATE_SUCCESS](state, true);
+
+ expect(state.updateSuccess).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/pane/getters_spec.js b/spec/frontend/ide/stores/modules/pane/getters_spec.js
new file mode 100644
index 00000000000..8a213323de0
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/pane/getters_spec.js
@@ -0,0 +1,55 @@
+import * as getters from '~/ide/stores/modules/pane/getters';
+import state from '~/ide/stores/modules/pane/state';
+
+describe('IDE pane module getters', () => {
+ const TEST_VIEW = 'test-view';
+ const TEST_KEEP_ALIVE_VIEWS = {
+ [TEST_VIEW]: true,
+ };
+
+ describe('isActiveView', () => {
+ it('returns true if given view matches currentView', () => {
+ const result = getters.isActiveView({ currentView: 'A' })('A');
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false if given view does not match currentView', () => {
+ const result = getters.isActiveView({ currentView: 'A' })('B');
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('isAliveView', () => {
+ it('returns true if given view is in keepAliveViews', () => {
+ const result = getters.isAliveView({ keepAliveViews: TEST_KEEP_ALIVE_VIEWS }, {})(TEST_VIEW);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns true if given view is active view and open', () => {
+ const result = getters.isAliveView(
+ { ...state(), isOpen: true },
+ { isActiveView: () => true },
+ )(TEST_VIEW);
+
+ expect(result).toBe(true);
+ });
+
+ it('returns false if given view is active view and closed', () => {
+ const result = getters.isAliveView(state(), { isActiveView: () => true })(TEST_VIEW);
+
+ expect(result).toBe(false);
+ });
+
+ it('returns false if given view is not activeView', () => {
+ const result = getters.isAliveView(
+ { ...state(), isOpen: true },
+ { isActiveView: () => false },
+ )(TEST_VIEW);
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/pane/mutations_spec.js b/spec/frontend/ide/stores/modules/pane/mutations_spec.js
new file mode 100644
index 00000000000..b5fcd35912e
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/pane/mutations_spec.js
@@ -0,0 +1,42 @@
+import state from '~/ide/stores/modules/pane/state';
+import mutations from '~/ide/stores/modules/pane/mutations';
+import * as types from '~/ide/stores/modules/pane/mutation_types';
+
+describe('IDE pane module mutations', () => {
+ const TEST_VIEW = 'test-view';
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('SET_OPEN', () => {
+ it('sets isOpen', () => {
+ mockedState.isOpen = false;
+
+ mutations[types.SET_OPEN](mockedState, true);
+
+ expect(mockedState.isOpen).toBe(true);
+ });
+ });
+
+ describe('SET_CURRENT_VIEW', () => {
+ it('sets currentView', () => {
+ mockedState.currentView = null;
+
+ mutations[types.SET_CURRENT_VIEW](mockedState, TEST_VIEW);
+
+ expect(mockedState.currentView).toEqual(TEST_VIEW);
+ });
+ });
+
+ describe('KEEP_ALIVE_VIEW', () => {
+ it('adds entry to keepAliveViews', () => {
+ mutations[types.KEEP_ALIVE_VIEW](mockedState, TEST_VIEW);
+
+ expect(mockedState.keepAliveViews).toEqual({
+ [TEST_VIEW]: true,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/pipelines/getters_spec.js b/spec/frontend/ide/stores/modules/pipelines/getters_spec.js
new file mode 100644
index 00000000000..4514896b5ea
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/pipelines/getters_spec.js
@@ -0,0 +1,40 @@
+import * as getters from '~/ide/stores/modules/pipelines/getters';
+import state from '~/ide/stores/modules/pipelines/state';
+
+describe('IDE pipeline getters', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = state();
+ });
+
+ describe('hasLatestPipeline', () => {
+ it('returns false when loading is true', () => {
+ mockedState.isLoadingPipeline = true;
+
+ expect(getters.hasLatestPipeline(mockedState)).toBe(false);
+ });
+
+ it('returns false when pipelines is null', () => {
+ mockedState.latestPipeline = null;
+
+ expect(getters.hasLatestPipeline(mockedState)).toBe(false);
+ });
+
+ it('returns false when loading is true & pipelines is null', () => {
+ mockedState.latestPipeline = null;
+ mockedState.isLoadingPipeline = true;
+
+ expect(getters.hasLatestPipeline(mockedState)).toBe(false);
+ });
+
+ it('returns true when loading is false & pipelines is an object', () => {
+ mockedState.latestPipeline = {
+ id: 1,
+ };
+ mockedState.isLoadingPipeline = false;
+
+ expect(getters.hasLatestPipeline(mockedState)).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/mutations/branch_spec.js b/spec/frontend/ide/stores/mutations/branch_spec.js
new file mode 100644
index 00000000000..29eb859ddaf
--- /dev/null
+++ b/spec/frontend/ide/stores/mutations/branch_spec.js
@@ -0,0 +1,40 @@
+import mutations from '~/ide/stores/mutations/branch';
+import state from '~/ide/stores/state';
+
+describe('Multi-file store branch mutations', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('SET_CURRENT_BRANCH', () => {
+ it('sets currentBranch', () => {
+ mutations.SET_CURRENT_BRANCH(localState, 'master');
+
+ expect(localState.currentBranchId).toBe('master');
+ });
+ });
+
+ describe('SET_BRANCH_COMMIT', () => {
+ it('sets the last commit on current project', () => {
+ localState.projects = {
+ Example: {
+ branches: {
+ master: {},
+ },
+ },
+ };
+
+ mutations.SET_BRANCH_COMMIT(localState, {
+ projectId: 'Example',
+ branchId: 'master',
+ commit: {
+ title: 'Example commit',
+ },
+ });
+
+ expect(localState.projects.Example.branches.master.commit.title).toBe('Example commit');
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/mutations/merge_request_spec.js b/spec/frontend/ide/stores/mutations/merge_request_spec.js
new file mode 100644
index 00000000000..e30ca22022f
--- /dev/null
+++ b/spec/frontend/ide/stores/mutations/merge_request_spec.js
@@ -0,0 +1,67 @@
+import mutations from '~/ide/stores/mutations/merge_request';
+import state from '~/ide/stores/state';
+
+describe('IDE store merge request mutations', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ localState.projects = { abcproject: { mergeRequests: {} } };
+
+ mutations.SET_MERGE_REQUEST(localState, {
+ projectPath: 'abcproject',
+ mergeRequestId: 1,
+ mergeRequest: {
+ title: 'mr',
+ },
+ });
+ });
+
+ describe('SET_CURRENT_MERGE_REQUEST', () => {
+ it('sets current merge request', () => {
+ mutations.SET_CURRENT_MERGE_REQUEST(localState, 2);
+
+ expect(localState.currentMergeRequestId).toBe(2);
+ });
+ });
+
+ describe('SET_MERGE_REQUEST', () => {
+ it('setsmerge request data', () => {
+ const newMr = localState.projects.abcproject.mergeRequests[1];
+
+ expect(newMr.title).toBe('mr');
+ expect(newMr.active).toBeTruthy();
+ });
+ });
+
+ describe('SET_MERGE_REQUEST_CHANGES', () => {
+ it('sets merge request changes', () => {
+ mutations.SET_MERGE_REQUEST_CHANGES(localState, {
+ projectPath: 'abcproject',
+ mergeRequestId: 1,
+ changes: {
+ diff: 'abc',
+ },
+ });
+
+ const newMr = localState.projects.abcproject.mergeRequests[1];
+
+ expect(newMr.changes.diff).toBe('abc');
+ });
+ });
+
+ describe('SET_MERGE_REQUEST_VERSIONS', () => {
+ it('sets merge request versions', () => {
+ mutations.SET_MERGE_REQUEST_VERSIONS(localState, {
+ projectPath: 'abcproject',
+ mergeRequestId: 1,
+ versions: [{ id: 123 }],
+ });
+
+ const newMr = localState.projects.abcproject.mergeRequests[1];
+
+ expect(newMr.versions.length).toBe(1);
+ expect(newMr.versions[0].id).toBe(123);
+ });
+ });
+});
diff --git a/spec/frontend/image_diff/view_types_spec.js b/spec/frontend/image_diff/view_types_spec.js
new file mode 100644
index 00000000000..e9639f46497
--- /dev/null
+++ b/spec/frontend/image_diff/view_types_spec.js
@@ -0,0 +1,24 @@
+import { viewTypes, isValidViewType } from '~/image_diff/view_types';
+
+describe('viewTypes', () => {
+ describe('isValidViewType', () => {
+ it('should return true for TWO_UP', () => {
+ expect(isValidViewType(viewTypes.TWO_UP)).toEqual(true);
+ });
+
+ it('should return true for SWIPE', () => {
+ expect(isValidViewType(viewTypes.SWIPE)).toEqual(true);
+ });
+
+ it('should return true for ONION_SKIN', () => {
+ expect(isValidViewType(viewTypes.ONION_SKIN)).toEqual(true);
+ });
+
+ it('should return false for non view types', () => {
+ expect(isValidViewType('some-view-type')).toEqual(false);
+ expect(isValidViewType(null)).toEqual(false);
+ expect(isValidViewType(undefined)).toEqual(false);
+ expect(isValidViewType('')).toEqual(false);
+ });
+ });
+});
diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_projects/store/getters_spec.js
new file mode 100644
index 00000000000..e5e4a95f473
--- /dev/null
+++ b/spec/frontend/import_projects/store/getters_spec.js
@@ -0,0 +1,83 @@
+import {
+ namespaceSelectOptions,
+ isImportingAnyRepo,
+ hasProviderRepos,
+ hasImportedProjects,
+} from '~/import_projects/store/getters';
+import state from '~/import_projects/store/state';
+
+describe('import_projects store getters', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('namespaceSelectOptions', () => {
+ const namespaces = [{ fullPath: 'namespace-0' }, { fullPath: 'namespace-1' }];
+ const defaultTargetNamespace = 'current-user';
+
+ it('returns an options array with a "Users" and "Groups" optgroups', () => {
+ localState.namespaces = namespaces;
+ localState.defaultTargetNamespace = defaultTargetNamespace;
+
+ const optionsArray = namespaceSelectOptions(localState);
+ const groupsGroup = optionsArray[0];
+ const usersGroup = optionsArray[1];
+
+ expect(groupsGroup.text).toBe('Groups');
+ expect(usersGroup.text).toBe('Users');
+
+ groupsGroup.children.forEach((child, index) => {
+ expect(child.id).toBe(namespaces[index].fullPath);
+ expect(child.text).toBe(namespaces[index].fullPath);
+ });
+
+ expect(usersGroup.children.length).toBe(1);
+ expect(usersGroup.children[0].id).toBe(defaultTargetNamespace);
+ expect(usersGroup.children[0].text).toBe(defaultTargetNamespace);
+ });
+ });
+
+ describe('isImportingAnyRepo', () => {
+ it('returns true if there are any reposBeingImported', () => {
+ localState.reposBeingImported = new Array(1);
+
+ expect(isImportingAnyRepo(localState)).toBe(true);
+ });
+
+ it('returns false if there are no reposBeingImported', () => {
+ localState.reposBeingImported = [];
+
+ expect(isImportingAnyRepo(localState)).toBe(false);
+ });
+ });
+
+ describe('hasProviderRepos', () => {
+ it('returns true if there are any providerRepos', () => {
+ localState.providerRepos = new Array(1);
+
+ expect(hasProviderRepos(localState)).toBe(true);
+ });
+
+ it('returns false if there are no providerRepos', () => {
+ localState.providerRepos = [];
+
+ expect(hasProviderRepos(localState)).toBe(false);
+ });
+ });
+
+ describe('hasImportedProjects', () => {
+ it('returns true if there are any importedProjects', () => {
+ localState.importedProjects = new Array(1);
+
+ expect(hasImportedProjects(localState)).toBe(true);
+ });
+
+ it('returns false if there are no importedProjects', () => {
+ localState.importedProjects = [];
+
+ expect(hasImportedProjects(localState)).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/import_projects/store/mutations_spec.js b/spec/frontend/import_projects/store/mutations_spec.js
new file mode 100644
index 00000000000..8db8e9819ba
--- /dev/null
+++ b/spec/frontend/import_projects/store/mutations_spec.js
@@ -0,0 +1,34 @@
+import * as types from '~/import_projects/store/mutation_types';
+import mutations from '~/import_projects/store/mutations';
+
+describe('import_projects store mutations', () => {
+ describe(types.RECEIVE_IMPORT_SUCCESS, () => {
+ it('removes repoId from reposBeingImported and providerRepos, adds to importedProjects', () => {
+ const repoId = 1;
+ const state = {
+ reposBeingImported: [repoId],
+ providerRepos: [{ id: repoId }],
+ importedProjects: [],
+ };
+ const importedProject = { id: repoId };
+
+ mutations[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId });
+
+ expect(state.reposBeingImported.includes(repoId)).toBe(false);
+ expect(state.providerRepos.some(repo => repo.id === repoId)).toBe(false);
+ expect(state.importedProjects.some(repo => repo.id === repoId)).toBe(true);
+ });
+ });
+
+ describe(types.RECEIVE_JOBS_SUCCESS, () => {
+ it('updates importStatus of existing importedProjects', () => {
+ const repoId = 1;
+ const state = { importedProjects: [{ id: repoId, importStatus: 'started' }] };
+ const updatedProjects = [{ id: repoId, importStatus: 'finished' }];
+
+ mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
+
+ expect(state.importedProjects[0].importStatus).toBe(updatedProjects[0].importStatus);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js
new file mode 100644
index 00000000000..a2df79bdda0
--- /dev/null
+++ b/spec/frontend/jobs/components/empty_state_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import component from '~/jobs/components/empty_state.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Empty State', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const props = {
+ illustrationPath: 'illustrations/pending_job_empty.svg',
+ illustrationSizeClass: 'svg-430',
+ title: 'This job has not started yet',
+ };
+
+ const content = 'This job is in pending state and is waiting to be picked by a runner';
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('renders image and title', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ });
+ });
+
+ it('renders img with provided path and size', () => {
+ expect(vm.$el.querySelector('img').getAttribute('src')).toEqual(props.illustrationPath);
+ expect(vm.$el.querySelector('.svg-content').classList).toContain(props.illustrationSizeClass);
+ });
+
+ it('renders provided title', () => {
+ expect(vm.$el.querySelector('.js-job-empty-state-title').textContent.trim()).toEqual(
+ props.title,
+ );
+ });
+ });
+
+ describe('with content', () => {
+ it('renders content', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-content').textContent.trim()).toEqual(
+ content,
+ );
+ });
+ });
+
+ describe('without content', () => {
+ it('does not render content', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-content')).toBeNull();
+ });
+ });
+
+ describe('with action', () => {
+ it('renders action', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ action: {
+ path: 'runner',
+ button_title: 'Check runner',
+ method: 'post',
+ },
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-action').getAttribute('href')).toEqual(
+ 'runner',
+ );
+ });
+ });
+
+ describe('without action', () => {
+ it('does not render action', () => {
+ vm = mountComponent(Component, {
+ ...props,
+ content,
+ action: null,
+ });
+
+ expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull();
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/erased_block_spec.js b/spec/frontend/jobs/components/erased_block_spec.js
new file mode 100644
index 00000000000..8e0433d3fb7
--- /dev/null
+++ b/spec/frontend/jobs/components/erased_block_spec.js
@@ -0,0 +1,56 @@
+import Vue from 'vue';
+import { getTimeago } from '~/lib/utils/datetime_utility';
+import component from '~/jobs/components/erased_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Erased block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ const erasedAt = '2016-11-07T11:11:16.525Z';
+ const timeago = getTimeago();
+ const formatedDate = timeago.format(erasedAt);
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with job erased by user', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ user: {
+ username: 'root',
+ web_url: 'gitlab.com/root',
+ },
+ erasedAt,
+ });
+ });
+
+ it('renders username and link', () => {
+ expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('gitlab.com/root');
+
+ expect(vm.$el.textContent).toContain('Job has been erased by');
+ expect(vm.$el.textContent).toContain('root');
+ });
+
+ it('renders erasedAt', () => {
+ expect(vm.$el.textContent).toContain(formatedDate);
+ });
+ });
+
+ describe('with erased job', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ erasedAt,
+ });
+ });
+
+ it('renders username and link', () => {
+ expect(vm.$el.textContent).toContain('Job has been erased');
+ });
+
+ it('renders erasedAt', () => {
+ expect(vm.$el.textContent).toContain(formatedDate);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/sidebar_detail_row_spec.js b/spec/frontend/jobs/components/sidebar_detail_row_spec.js
new file mode 100644
index 00000000000..42d11266dad
--- /dev/null
+++ b/spec/frontend/jobs/components/sidebar_detail_row_spec.js
@@ -0,0 +1,61 @@
+import Vue from 'vue';
+import sidebarDetailRow from '~/jobs/components/sidebar_detail_row.vue';
+
+describe('Sidebar detail row', () => {
+ let SidebarDetailRow;
+ let vm;
+
+ beforeEach(() => {
+ SidebarDetailRow = Vue.extend(sidebarDetailRow);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render no title', () => {
+ vm = new SidebarDetailRow({
+ propsData: {
+ value: 'this is the value',
+ },
+ }).$mount();
+
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('this is the value');
+ });
+
+ beforeEach(() => {
+ vm = new SidebarDetailRow({
+ propsData: {
+ title: 'this is the title',
+ value: 'this is the value',
+ },
+ }).$mount();
+ });
+
+ it('should render provided title and value', () => {
+ expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual(
+ 'this is the title: this is the value',
+ );
+ });
+
+ describe('when helpUrl not provided', () => {
+ it('should not render help', () => {
+ expect(vm.$el.querySelector('.help-button')).toBeNull();
+ });
+ });
+
+ describe('when helpUrl provided', () => {
+ beforeEach(() => {
+ vm = new SidebarDetailRow({
+ propsData: {
+ helpUrl: 'help url',
+ value: 'foo',
+ },
+ }).$mount();
+ });
+
+ it('should render help', () => {
+ expect(vm.$el.querySelector('.help-button a').getAttribute('href')).toEqual('help url');
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/stuck_block_spec.js b/spec/frontend/jobs/components/stuck_block_spec.js
new file mode 100644
index 00000000000..c320793b2be
--- /dev/null
+++ b/spec/frontend/jobs/components/stuck_block_spec.js
@@ -0,0 +1,81 @@
+import Vue from 'vue';
+import component from '~/jobs/components/stuck_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Stuck Block Job component', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('with no runners for project', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ hasNoRunnersForProject: true,
+ runnersPath: '/root/project/runners#js-runners-settings',
+ });
+ });
+
+ it('renders only information about project not having runners', () => {
+ expect(vm.$el.querySelector('.js-stuck-no-runners')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-stuck-with-tags')).toBeNull();
+ expect(vm.$el.querySelector('.js-stuck-no-active-runner')).toBeNull();
+ });
+
+ it('renders link to runners page', () => {
+ expect(vm.$el.querySelector('.js-runners-path').getAttribute('href')).toEqual(
+ '/root/project/runners#js-runners-settings',
+ );
+ });
+ });
+
+ describe('with tags', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ hasNoRunnersForProject: false,
+ tags: ['docker', 'gitlab-org'],
+ runnersPath: '/root/project/runners#js-runners-settings',
+ });
+ });
+
+ it('renders information about the tags not being set', () => {
+ expect(vm.$el.querySelector('.js-stuck-no-runners')).toBeNull();
+ expect(vm.$el.querySelector('.js-stuck-with-tags')).not.toBeNull();
+ expect(vm.$el.querySelector('.js-stuck-no-active-runner')).toBeNull();
+ });
+
+ it('renders tags', () => {
+ expect(vm.$el.textContent).toContain('docker');
+ expect(vm.$el.textContent).toContain('gitlab-org');
+ });
+
+ it('renders link to runners page', () => {
+ expect(vm.$el.querySelector('.js-runners-path').getAttribute('href')).toEqual(
+ '/root/project/runners#js-runners-settings',
+ );
+ });
+ });
+
+ describe('without active runners', () => {
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ hasNoRunnersForProject: false,
+ runnersPath: '/root/project/runners#js-runners-settings',
+ });
+ });
+
+ it('renders information about project not having runners', () => {
+ expect(vm.$el.querySelector('.js-stuck-no-runners')).toBeNull();
+ expect(vm.$el.querySelector('.js-stuck-with-tags')).toBeNull();
+ expect(vm.$el.querySelector('.js-stuck-no-active-runner')).not.toBeNull();
+ });
+
+ it('renders link to runners page', () => {
+ expect(vm.$el.querySelector('.js-runners-path').getAttribute('href')).toEqual(
+ '/root/project/runners#js-runners-settings',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/jobs/store/getters_spec.js b/spec/frontend/jobs/store/getters_spec.js
new file mode 100644
index 00000000000..379114c3737
--- /dev/null
+++ b/spec/frontend/jobs/store/getters_spec.js
@@ -0,0 +1,243 @@
+import * as getters from '~/jobs/store/getters';
+import state from '~/jobs/store/state';
+
+describe('Job Store Getters', () => {
+ let localState;
+
+ beforeEach(() => {
+ localState = state();
+ });
+
+ describe('headerTime', () => {
+ describe('when the job has started key', () => {
+ it('returns started key', () => {
+ const started = '2018-08-31T16:20:49.023Z';
+ localState.job.started = started;
+
+ expect(getters.headerTime(localState)).toEqual(started);
+ });
+ });
+
+ describe('when the job does not have started key', () => {
+ it('returns created_at key', () => {
+ const created = '2018-08-31T16:20:49.023Z';
+ localState.job.created_at = created;
+
+ expect(getters.headerTime(localState)).toEqual(created);
+ });
+ });
+ });
+
+ describe('shouldRenderCalloutMessage', () => {
+ describe('with status and callout message', () => {
+ it('returns true', () => {
+ localState.job.callout_message = 'Callout message';
+ localState.job.status = { icon: 'passed' };
+
+ expect(getters.shouldRenderCalloutMessage(localState)).toEqual(true);
+ });
+ });
+
+ describe('without status & with callout message', () => {
+ it('returns false', () => {
+ localState.job.callout_message = 'Callout message';
+
+ expect(getters.shouldRenderCalloutMessage(localState)).toEqual(false);
+ });
+ });
+
+ describe('with status & without callout message', () => {
+ it('returns false', () => {
+ localState.job.status = { icon: 'passed' };
+
+ expect(getters.shouldRenderCalloutMessage(localState)).toEqual(false);
+ });
+ });
+ });
+
+ describe('shouldRenderTriggeredLabel', () => {
+ describe('when started equals null', () => {
+ it('returns false', () => {
+ localState.job.started = null;
+
+ expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(false);
+ });
+ });
+
+ describe('when started equals string', () => {
+ it('returns true', () => {
+ localState.job.started = '2018-08-31T16:20:49.023Z';
+
+ expect(getters.shouldRenderTriggeredLabel(localState)).toEqual(true);
+ });
+ });
+ });
+
+ describe('hasEnvironment', () => {
+ describe('without `deployment_status`', () => {
+ it('returns false', () => {
+ expect(getters.hasEnvironment(localState)).toEqual(false);
+ });
+ });
+
+ describe('with an empty object for `deployment_status`', () => {
+ it('returns false', () => {
+ localState.job.deployment_status = {};
+
+ expect(getters.hasEnvironment(localState)).toEqual(false);
+ });
+ });
+
+ describe('when `deployment_status` is defined and not empty', () => {
+ it('returns true', () => {
+ localState.job.deployment_status = {
+ status: 'creating',
+ environment: {
+ last_deployment: {},
+ },
+ };
+
+ expect(getters.hasEnvironment(localState)).toEqual(true);
+ });
+ });
+ });
+
+ describe('hasTrace', () => {
+ describe('when has_trace is true', () => {
+ it('returns true', () => {
+ localState.job.has_trace = true;
+ localState.job.status = {};
+
+ expect(getters.hasTrace(localState)).toEqual(true);
+ });
+ });
+
+ describe('when job is running', () => {
+ it('returns true', () => {
+ localState.job.has_trace = false;
+ localState.job.status = { group: 'running' };
+
+ expect(getters.hasTrace(localState)).toEqual(true);
+ });
+ });
+
+ describe('when has_trace is false and job is not running', () => {
+ it('returns false', () => {
+ localState.job.has_trace = false;
+ localState.job.status = { group: 'pending' };
+
+ expect(getters.hasTrace(localState)).toEqual(false);
+ });
+ });
+ });
+
+ describe('emptyStateIllustration', () => {
+ describe('with defined illustration', () => {
+ it('returns the state illustration object', () => {
+ localState.job.status = {
+ illustration: {
+ path: 'foo',
+ },
+ };
+
+ expect(getters.emptyStateIllustration(localState)).toEqual({ path: 'foo' });
+ });
+ });
+
+ describe('when illustration is not defined', () => {
+ it('returns an empty object', () => {
+ expect(getters.emptyStateIllustration(localState)).toEqual({});
+ });
+ });
+ });
+
+ describe('shouldRenderSharedRunnerLimitWarning', () => {
+ describe('without runners information', () => {
+ it('returns false', () => {
+ expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false);
+ });
+ });
+
+ describe('with runners information', () => {
+ describe('when used quota is less than limit', () => {
+ it('returns false', () => {
+ localState.job.runners = {
+ quota: {
+ used: 33,
+ limit: 2000,
+ },
+ available: true,
+ online: true,
+ };
+
+ expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(false);
+ });
+ });
+
+ describe('when used quota is equal to limit', () => {
+ it('returns true', () => {
+ localState.job.runners = {
+ quota: {
+ used: 2000,
+ limit: 2000,
+ },
+ available: true,
+ online: true,
+ };
+
+ expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true);
+ });
+ });
+
+ describe('when used quota is bigger than limit', () => {
+ it('returns true', () => {
+ localState.job.runners = {
+ quota: {
+ used: 2002,
+ limit: 2000,
+ },
+ available: true,
+ online: true,
+ };
+
+ expect(getters.shouldRenderSharedRunnerLimitWarning(localState)).toEqual(true);
+ });
+ });
+ });
+ });
+
+ describe('hasRunnersForProject', () => {
+ describe('with available and offline runners', () => {
+ it('returns true', () => {
+ localState.job.runners = {
+ available: true,
+ online: false,
+ };
+
+ expect(getters.hasRunnersForProject(localState)).toEqual(true);
+ });
+ });
+
+ describe('with non available runners', () => {
+ it('returns false', () => {
+ localState.job.runners = {
+ available: false,
+ online: false,
+ };
+
+ expect(getters.hasRunnersForProject(localState)).toEqual(false);
+ });
+ });
+
+ describe('with online runners', () => {
+ it('returns false', () => {
+ localState.job.runners = {
+ available: false,
+ online: true,
+ };
+
+ expect(getters.hasRunnersForProject(localState)).toEqual(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/store/mutations_spec.js b/spec/frontend/jobs/store/mutations_spec.js
new file mode 100644
index 00000000000..d7908efcf13
--- /dev/null
+++ b/spec/frontend/jobs/store/mutations_spec.js
@@ -0,0 +1,230 @@
+import state from '~/jobs/store/state';
+import mutations from '~/jobs/store/mutations';
+import * as types from '~/jobs/store/mutation_types';
+
+describe('Jobs Store Mutations', () => {
+ let stateCopy;
+
+ const html =
+ 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- : Writing /builds/ab89e95b0fa0b9272ea0c797b76908f24d36992630e9325273a4ce3.png<br>I';
+
+ beforeEach(() => {
+ stateCopy = state();
+ });
+
+ describe('SET_JOB_ENDPOINT', () => {
+ it('should set jobEndpoint', () => {
+ mutations[types.SET_JOB_ENDPOINT](stateCopy, 'job/21312321.json');
+
+ expect(stateCopy.jobEndpoint).toEqual('job/21312321.json');
+ });
+ });
+
+ describe('HIDE_SIDEBAR', () => {
+ it('should set isSidebarOpen to false', () => {
+ mutations[types.HIDE_SIDEBAR](stateCopy);
+
+ expect(stateCopy.isSidebarOpen).toEqual(false);
+ });
+ });
+
+ describe('SHOW_SIDEBAR', () => {
+ it('should set isSidebarOpen to true', () => {
+ mutations[types.SHOW_SIDEBAR](stateCopy);
+
+ expect(stateCopy.isSidebarOpen).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_TRACE_SUCCESS', () => {
+ describe('when trace has state', () => {
+ it('sets traceState', () => {
+ const stateLog =
+ 'eyJvZmZzZXQiOjczNDQ1MSwibl9vcGVuX3RhZ3MiOjAsImZnX2NvbG9yIjpudWxsLCJiZ19jb2xvciI6bnVsbCwic3R5bGVfbWFzayI6MH0=';
+ mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, {
+ state: stateLog,
+ });
+
+ expect(stateCopy.traceState).toEqual(stateLog);
+ });
+ });
+
+ describe('when traceSize is smaller than the total size', () => {
+ it('sets isTraceSizeVisible to true', () => {
+ mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, { total: 51184600, size: 1231 });
+
+ expect(stateCopy.isTraceSizeVisible).toEqual(true);
+ });
+ });
+
+ describe('when traceSize is bigger than the total size', () => {
+ it('sets isTraceSizeVisible to false', () => {
+ const copy = Object.assign({}, stateCopy, { traceSize: 5118460, size: 2321312 });
+
+ mutations[types.RECEIVE_TRACE_SUCCESS](copy, { total: 511846 });
+
+ expect(copy.isTraceSizeVisible).toEqual(false);
+ });
+ });
+
+ it('sets trace, trace size and isTraceComplete', () => {
+ mutations[types.RECEIVE_TRACE_SUCCESS](stateCopy, {
+ append: true,
+ html,
+ size: 511846,
+ complete: true,
+ });
+
+ expect(stateCopy.trace).toEqual(html);
+ expect(stateCopy.traceSize).toEqual(511846);
+ expect(stateCopy.isTraceComplete).toEqual(true);
+ });
+ });
+
+ describe('STOP_POLLING_TRACE', () => {
+ it('sets isTraceComplete to true', () => {
+ mutations[types.STOP_POLLING_TRACE](stateCopy);
+
+ expect(stateCopy.isTraceComplete).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_TRACE_ERROR', () => {
+ it('resets trace state and sets error to true', () => {
+ mutations[types.RECEIVE_TRACE_ERROR](stateCopy);
+
+ expect(stateCopy.isTraceComplete).toEqual(true);
+ });
+ });
+
+ describe('REQUEST_JOB', () => {
+ it('sets isLoading to true', () => {
+ mutations[types.REQUEST_JOB](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_JOB_SUCCESS', () => {
+ it('sets is loading to false', () => {
+ mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 });
+
+ expect(stateCopy.isLoading).toEqual(false);
+ });
+
+ it('sets hasError to false', () => {
+ mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 });
+
+ expect(stateCopy.hasError).toEqual(false);
+ });
+
+ it('sets job data', () => {
+ mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321 });
+
+ expect(stateCopy.job).toEqual({ id: 1312321 });
+ });
+
+ it('sets selectedStage when the selectedStage is empty', () => {
+ expect(stateCopy.selectedStage).toEqual('');
+ mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321, stage: 'deploy' });
+
+ expect(stateCopy.selectedStage).toEqual('deploy');
+ });
+
+ it('does not set selectedStage when the selectedStage is not More', () => {
+ stateCopy.selectedStage = 'notify';
+
+ expect(stateCopy.selectedStage).toEqual('notify');
+ mutations[types.RECEIVE_JOB_SUCCESS](stateCopy, { id: 1312321, stage: 'deploy' });
+
+ expect(stateCopy.selectedStage).toEqual('notify');
+ });
+ });
+
+ describe('RECEIVE_JOB_ERROR', () => {
+ it('resets job data', () => {
+ mutations[types.RECEIVE_JOB_ERROR](stateCopy);
+
+ expect(stateCopy.isLoading).toEqual(false);
+ expect(stateCopy.job).toEqual({});
+ });
+ });
+
+ describe('REQUEST_STAGES', () => {
+ it('sets isLoadingStages to true', () => {
+ mutations[types.REQUEST_STAGES](stateCopy);
+
+ expect(stateCopy.isLoadingStages).toEqual(true);
+ });
+ });
+
+ describe('RECEIVE_STAGES_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_STAGES_SUCCESS](stateCopy, [{ name: 'build' }]);
+ });
+
+ it('sets isLoadingStages to false', () => {
+ expect(stateCopy.isLoadingStages).toEqual(false);
+ });
+
+ it('sets stages', () => {
+ expect(stateCopy.stages).toEqual([{ name: 'build' }]);
+ });
+ });
+
+ describe('RECEIVE_STAGES_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_STAGES_ERROR](stateCopy);
+ });
+
+ it('sets isLoadingStages to false', () => {
+ expect(stateCopy.isLoadingStages).toEqual(false);
+ });
+
+ it('resets stages', () => {
+ expect(stateCopy.stages).toEqual([]);
+ });
+ });
+
+ describe('REQUEST_JOBS_FOR_STAGE', () => {
+ it('sets isLoadingStages to true', () => {
+ mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' });
+
+ expect(stateCopy.isLoadingJobs).toEqual(true);
+ });
+
+ it('sets selectedStage', () => {
+ mutations[types.REQUEST_JOBS_FOR_STAGE](stateCopy, { name: 'deploy' });
+
+ expect(stateCopy.selectedStage).toEqual('deploy');
+ });
+ });
+
+ describe('RECEIVE_JOBS_FOR_STAGE_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_JOBS_FOR_STAGE_SUCCESS](stateCopy, [{ name: 'karma' }]);
+ });
+
+ it('sets isLoadingJobs to false', () => {
+ expect(stateCopy.isLoadingJobs).toEqual(false);
+ });
+
+ it('sets jobs', () => {
+ expect(stateCopy.jobs).toEqual([{ name: 'karma' }]);
+ });
+ });
+
+ describe('RECEIVE_JOBS_FOR_STAGE_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_JOBS_FOR_STAGE_ERROR](stateCopy);
+ });
+
+ it('sets isLoadingJobs to false', () => {
+ expect(stateCopy.isLoadingJobs).toEqual(false);
+ });
+
+ it('resets jobs', () => {
+ expect(stateCopy.jobs).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels_select_spec.js
new file mode 100644
index 00000000000..acfdc885032
--- /dev/null
+++ b/spec/frontend/labels_select_spec.js
@@ -0,0 +1,52 @@
+import $ from 'jquery';
+import LabelsSelect from '~/labels_select';
+
+const mockUrl = '/foo/bar/url';
+
+const mockLabels = [
+ {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ text_color: '#FFFFFF',
+ },
+];
+
+describe('LabelsSelect', () => {
+ describe('getLabelTemplate', () => {
+ const label = mockLabels[0];
+ let $labelEl;
+
+ beforeEach(() => {
+ $labelEl = $(
+ LabelsSelect.getLabelTemplate({
+ labels: mockLabels,
+ issueUpdateURL: mockUrl,
+ }),
+ );
+ });
+
+ it('generated label item template has correct label URL', () => {
+ expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
+ });
+
+ it('generated label item template has correct label title', () => {
+ expect($labelEl.find('span.label').text()).toBe(label.title);
+ });
+
+ it('generated label item template has label description as title attribute', () => {
+ expect($labelEl.find('span.label').attr('title')).toBe(label.description);
+ });
+
+ it('generated label item template has correct label styles', () => {
+ expect($labelEl.find('span.label').attr('style')).toBe(
+ `background-color: ${label.color}; color: ${label.text_color};`,
+ );
+ });
+
+ it('generated label item has a badge class', () => {
+ expect($labelEl.find('span').hasClass('badge')).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/autosave_spec.js b/spec/frontend/lib/utils/autosave_spec.js
new file mode 100644
index 00000000000..12e97f6cdec
--- /dev/null
+++ b/spec/frontend/lib/utils/autosave_spec.js
@@ -0,0 +1,64 @@
+import { clearDraft, getDraft, updateDraft } from '~/lib/utils/autosave';
+
+describe('autosave utils', () => {
+ const autosaveKey = 'dummy-autosave-key';
+ const text = 'some dummy text';
+
+ describe('clearDraft', () => {
+ beforeEach(() => {
+ localStorage.setItem(`autosave/${autosaveKey}`, text);
+ });
+
+ afterEach(() => {
+ localStorage.removeItem(`autosave/${autosaveKey}`);
+ });
+
+ it('removes the draft from localStorage', () => {
+ clearDraft(autosaveKey);
+
+ expect(localStorage.getItem(`autosave/${autosaveKey}`)).toBe(null);
+ });
+ });
+
+ describe('getDraft', () => {
+ beforeEach(() => {
+ localStorage.setItem(`autosave/${autosaveKey}`, text);
+ });
+
+ afterEach(() => {
+ localStorage.removeItem(`autosave/${autosaveKey}`);
+ });
+
+ it('returns the draft from localStorage', () => {
+ const result = getDraft(autosaveKey);
+
+ expect(result).toBe(text);
+ });
+
+ it('returns null if no entry exists in localStorage', () => {
+ localStorage.removeItem(`autosave/${autosaveKey}`);
+
+ const result = getDraft(autosaveKey);
+
+ expect(result).toBe(null);
+ });
+ });
+
+ describe('updateDraft', () => {
+ beforeEach(() => {
+ localStorage.setItem(`autosave/${autosaveKey}`, text);
+ });
+
+ afterEach(() => {
+ localStorage.removeItem(`autosave/${autosaveKey}`);
+ });
+
+ it('removes the draft from localStorage', () => {
+ const newText = 'new text';
+
+ updateDraft(autosaveKey, newText);
+
+ expect(localStorage.getItem(`autosave/${autosaveKey}`)).toBe(newText);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/cache_spec.js b/spec/frontend/lib/utils/cache_spec.js
new file mode 100644
index 00000000000..2fe02a7592c
--- /dev/null
+++ b/spec/frontend/lib/utils/cache_spec.js
@@ -0,0 +1,65 @@
+import Cache from '~/lib/utils/cache';
+
+describe('Cache', () => {
+ const dummyKey = 'just some key';
+ const dummyValue = 'more than a value';
+ let cache;
+
+ beforeEach(() => {
+ cache = new Cache();
+ });
+
+ describe('get', () => {
+ it('return cached data', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+
+ expect(cache.get(dummyKey)).toBe(dummyValue);
+ });
+
+ it('returns undefined for missing data', () => {
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ expect(cache.get(dummyKey)).toBe(undefined);
+ });
+ });
+
+ describe('hasData', () => {
+ it('return true for cached data', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+
+ expect(cache.hasData(dummyKey)).toBe(true);
+ });
+
+ it('returns false for missing data', () => {
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ expect(cache.hasData(dummyKey)).toBe(false);
+ });
+ });
+
+ describe('remove', () => {
+ it('removes data from cache', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+
+ cache.remove(dummyKey);
+
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ });
+
+ it('does nothing for missing data', () => {
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+
+ cache.remove(dummyKey);
+
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ });
+
+ it('does not remove wrong data', () => {
+ cache.internalStorage[dummyKey] = dummyValue;
+ cache.internalStorage[dummyKey + dummyKey] = dummyValue + dummyValue;
+
+ cache.remove(dummyKey);
+
+ expect(cache.internalStorage[dummyKey]).toBe(undefined);
+ expect(cache.internalStorage[dummyKey + dummyKey]).toBe(dummyValue + dummyValue);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/grammar_spec.js b/spec/frontend/lib/utils/grammar_spec.js
new file mode 100644
index 00000000000..377b2ffb48c
--- /dev/null
+++ b/spec/frontend/lib/utils/grammar_spec.js
@@ -0,0 +1,35 @@
+import * as grammar from '~/lib/utils/grammar';
+
+describe('utils/grammar', () => {
+ describe('toNounSeriesText', () => {
+ it('with empty items returns empty string', () => {
+ expect(grammar.toNounSeriesText([])).toBe('');
+ });
+
+ it('with single item returns item', () => {
+ const items = ['Lorem Ipsum'];
+
+ expect(grammar.toNounSeriesText(items)).toBe(items[0]);
+ });
+
+ it('with 2 items returns item1 and item2', () => {
+ const items = ['Dolar', 'Sit Amit'];
+
+ expect(grammar.toNounSeriesText(items)).toBe(`${items[0]} and ${items[1]}`);
+ });
+
+ it('with 3 items returns comma separated series', () => {
+ const items = ['Lorem', 'Ipsum', 'dolar'];
+ const expected = 'Lorem, Ipsum, and dolar';
+
+ expect(grammar.toNounSeriesText(items)).toBe(expected);
+ });
+
+ it('with 6 items returns comma separated series', () => {
+ const items = ['Lorem', 'ipsum', 'dolar', 'sit', 'amit', 'consectetur'];
+ const expected = 'Lorem, ipsum, dolar, sit, amit, and consectetur';
+
+ expect(grammar.toNounSeriesText(items)).toBe(expected);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/image_utility_spec.js b/spec/frontend/lib/utils/image_utility_spec.js
new file mode 100644
index 00000000000..a7eff419fba
--- /dev/null
+++ b/spec/frontend/lib/utils/image_utility_spec.js
@@ -0,0 +1,32 @@
+import { isImageLoaded } from '~/lib/utils/image_utility';
+
+describe('imageUtility', () => {
+ describe('isImageLoaded', () => {
+ it('should return false when image.complete is false', () => {
+ const element = {
+ complete: false,
+ naturalHeight: 100,
+ };
+
+ expect(isImageLoaded(element)).toEqual(false);
+ });
+
+ it('should return false when naturalHeight = 0', () => {
+ const element = {
+ complete: true,
+ naturalHeight: 0,
+ };
+
+ expect(isImageLoaded(element)).toEqual(false);
+ });
+
+ it('should return true when image.complete and naturalHeight != 0', () => {
+ const element = {
+ complete: true,
+ naturalHeight: 100,
+ };
+
+ expect(isImageLoaded(element)).toEqual(true);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
new file mode 100644
index 00000000000..818404bad81
--- /dev/null
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -0,0 +1,101 @@
+import {
+ formatRelevantDigits,
+ bytesToKiB,
+ bytesToMiB,
+ bytesToGiB,
+ numberToHumanSize,
+ sum,
+} from '~/lib/utils/number_utils';
+
+describe('Number Utils', () => {
+ describe('formatRelevantDigits', () => {
+ it('returns an empty string when the number is NaN', () => {
+ expect(formatRelevantDigits('fail')).toBe('');
+ });
+
+ it('returns 4 decimals when there is 4 plus digits to the left', () => {
+ const formattedNumber = formatRelevantDigits('1000.1234567');
+ const rightFromDecimal = formattedNumber.split('.')[1];
+ const leftFromDecimal = formattedNumber.split('.')[0];
+
+ expect(rightFromDecimal.length).toBe(4);
+ expect(leftFromDecimal.length).toBe(4);
+ });
+
+ it('returns 3 decimals when there is 1 digit to the left', () => {
+ const formattedNumber = formatRelevantDigits('0.1234567');
+ const rightFromDecimal = formattedNumber.split('.')[1];
+ const leftFromDecimal = formattedNumber.split('.')[0];
+
+ expect(rightFromDecimal.length).toBe(3);
+ expect(leftFromDecimal.length).toBe(1);
+ });
+
+ it('returns 2 decimals when there is 2 digits to the left', () => {
+ const formattedNumber = formatRelevantDigits('10.1234567');
+ const rightFromDecimal = formattedNumber.split('.')[1];
+ const leftFromDecimal = formattedNumber.split('.')[0];
+
+ expect(rightFromDecimal.length).toBe(2);
+ expect(leftFromDecimal.length).toBe(2);
+ });
+
+ it('returns 1 decimal when there is 3 digits to the left', () => {
+ const formattedNumber = formatRelevantDigits('100.1234567');
+ const rightFromDecimal = formattedNumber.split('.')[1];
+ const leftFromDecimal = formattedNumber.split('.')[0];
+
+ expect(rightFromDecimal.length).toBe(1);
+ expect(leftFromDecimal.length).toBe(3);
+ });
+ });
+
+ describe('bytesToKiB', () => {
+ it('calculates KiB for the given bytes', () => {
+ expect(bytesToKiB(1024)).toEqual(1);
+ expect(bytesToKiB(1000)).toEqual(0.9765625);
+ });
+ });
+
+ describe('bytesToMiB', () => {
+ it('calculates MiB for the given bytes', () => {
+ expect(bytesToMiB(1048576)).toEqual(1);
+ expect(bytesToMiB(1000000)).toEqual(0.95367431640625);
+ });
+ });
+
+ describe('bytesToGiB', () => {
+ it('calculates GiB for the given bytes', () => {
+ expect(bytesToGiB(1073741824)).toEqual(1);
+ expect(bytesToGiB(10737418240)).toEqual(10);
+ });
+ });
+
+ describe('numberToHumanSize', () => {
+ it('should return bytes', () => {
+ expect(numberToHumanSize(654)).toEqual('654 bytes');
+ });
+
+ it('should return KiB', () => {
+ expect(numberToHumanSize(1079)).toEqual('1.05 KiB');
+ });
+
+ it('should return MiB', () => {
+ expect(numberToHumanSize(10485764)).toEqual('10.00 MiB');
+ });
+
+ it('should return GiB', () => {
+ expect(numberToHumanSize(10737418240)).toEqual('10.00 GiB');
+ });
+ });
+
+ describe('sum', () => {
+ it('should add up two values', () => {
+ expect(sum(1, 2)).toEqual(3);
+ });
+
+ it('should add up all the values in an array when passed to a reducer', () => {
+ expect([1, 2, 3, 4, 5].reduce(sum)).toEqual(15);
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
new file mode 100644
index 00000000000..0a266b19ea5
--- /dev/null
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -0,0 +1,154 @@
+import * as textUtils from '~/lib/utils/text_utility';
+
+describe('text_utility', () => {
+ describe('addDelimiter', () => {
+ it('should add a delimiter to the given string', () => {
+ expect(textUtils.addDelimiter('1234')).toEqual('1,234');
+ expect(textUtils.addDelimiter('222222')).toEqual('222,222');
+ });
+
+ it('should not add a delimiter if string contains no numbers', () => {
+ expect(textUtils.addDelimiter('aaaa')).toEqual('aaaa');
+ });
+ });
+
+ describe('highCountTrim', () => {
+ it('returns 99+ for count >= 100', () => {
+ expect(textUtils.highCountTrim(105)).toBe('99+');
+ expect(textUtils.highCountTrim(100)).toBe('99+');
+ });
+
+ it('returns exact number for count < 100', () => {
+ expect(textUtils.highCountTrim(45)).toBe(45);
+ });
+ });
+
+ describe('capitalizeFirstCharacter', () => {
+ it('returns string with first letter capitalized', () => {
+ expect(textUtils.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab');
+ expect(textUtils.highCountTrim(105)).toBe('99+');
+ expect(textUtils.highCountTrim(100)).toBe('99+');
+ });
+ });
+
+ describe('humanize', () => {
+ it('should remove underscores and uppercase the first letter', () => {
+ expect(textUtils.humanize('foo_bar')).toEqual('Foo bar');
+ });
+ });
+
+ describe('pluralize', () => {
+ it('should pluralize given string', () => {
+ expect(textUtils.pluralize('test', 2)).toBe('tests');
+ });
+
+ it('should pluralize when count is 0', () => {
+ expect(textUtils.pluralize('test', 0)).toBe('tests');
+ });
+
+ it('should not pluralize when count is 1', () => {
+ expect(textUtils.pluralize('test', 1)).toBe('test');
+ });
+ });
+
+ describe('dasherize', () => {
+ it('should replace underscores with dashes', () => {
+ expect(textUtils.dasherize('foo_bar_foo')).toEqual('foo-bar-foo');
+ });
+ });
+
+ describe('slugify', () => {
+ it('should remove accents and convert to lower case', () => {
+ expect(textUtils.slugify('João')).toEqual('joão');
+ });
+ });
+
+ describe('slugifyWithHyphens', () => {
+ it('should replaces whitespaces with hyphens and convert to lower case', () => {
+ expect(textUtils.slugifyWithHyphens('My Input String')).toEqual('my-input-string');
+ });
+ });
+
+ describe('stripHtml', () => {
+ it('replaces html tag with the default replacement', () => {
+ expect(textUtils.stripHtml('This is a text with <p>html</p>.')).toEqual(
+ 'This is a text with html.',
+ );
+ });
+
+ it('replaces html tags with the provided replacement', () => {
+ expect(textUtils.stripHtml('This is a text with <p>html</p>.', ' ')).toEqual(
+ 'This is a text with html .',
+ );
+ });
+
+ it('passes through with null string input', () => {
+ expect(textUtils.stripHtml(null, ' ')).toEqual(null);
+ });
+
+ it('passes through with undefined string input', () => {
+ expect(textUtils.stripHtml(undefined, ' ')).toEqual(undefined);
+ });
+ });
+
+ describe('convertToCamelCase', () => {
+ it('converts snake_case string to camelCase string', () => {
+ expect(textUtils.convertToCamelCase('snake_case')).toBe('snakeCase');
+ });
+ });
+
+ describe('convertToSentenceCase', () => {
+ it('converts Sentence Case to Sentence case', () => {
+ expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world');
+ });
+ });
+
+ describe('truncateSha', () => {
+ it('shortens SHAs to 8 characters', () => {
+ expect(textUtils.truncateSha('verylongsha')).toBe('verylong');
+ });
+
+ it('leaves short SHAs as is', () => {
+ expect(textUtils.truncateSha('shortsha')).toBe('shortsha');
+ });
+ });
+
+ describe('splitCamelCase', () => {
+ it('separates a PascalCase word to two', () => {
+ expect(textUtils.splitCamelCase('HelloWorld')).toBe('Hello World');
+ });
+ });
+
+ describe('getFirstCharacterCapitalized', () => {
+ it('returns the first character capitalized, if first character is alphabetic', () => {
+ expect(textUtils.getFirstCharacterCapitalized('loremIpsumDolar')).toEqual('L');
+ expect(textUtils.getFirstCharacterCapitalized('Sit amit !')).toEqual('S');
+ });
+
+ it('returns the first character, if first character is non-alphabetic', () => {
+ expect(textUtils.getFirstCharacterCapitalized(' lorem')).toEqual(' ');
+ expect(textUtils.getFirstCharacterCapitalized('%#!')).toEqual('%');
+ });
+
+ it('returns an empty string, if string is falsey', () => {
+ expect(textUtils.getFirstCharacterCapitalized('')).toEqual('');
+ expect(textUtils.getFirstCharacterCapitalized(null)).toEqual('');
+ });
+ });
+
+ describe('truncatePathMiddleToLength', () => {
+ it('does not truncate text', () => {
+ expect(textUtils.truncatePathMiddleToLength('app/test', 50)).toEqual('app/test');
+ });
+
+ it('truncates middle of the path', () => {
+ expect(textUtils.truncatePathMiddleToLength('app/test/diff', 13)).toEqual('app/…/diff');
+ });
+
+ it('truncates multiple times in the middle of the path', () => {
+ expect(textUtils.truncatePathMiddleToLength('app/test/merge_request/diff', 13)).toEqual(
+ 'app/…/…/diff',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/locale/ensure_single_line_spec.js b/spec/frontend/locale/ensure_single_line_spec.js
new file mode 100644
index 00000000000..20b04cab9c8
--- /dev/null
+++ b/spec/frontend/locale/ensure_single_line_spec.js
@@ -0,0 +1,38 @@
+import ensureSingleLine from '~/locale/ensure_single_line';
+
+describe('locale', () => {
+ describe('ensureSingleLine', () => {
+ it('should remove newlines at the start of the string', () => {
+ const result = 'Test';
+
+ expect(ensureSingleLine(`\n${result}`)).toBe(result);
+ expect(ensureSingleLine(`\t\n\t${result}`)).toBe(result);
+ expect(ensureSingleLine(`\r\n${result}`)).toBe(result);
+ expect(ensureSingleLine(`\r\n ${result}`)).toBe(result);
+ expect(ensureSingleLine(`\r ${result}`)).toBe(result);
+ expect(ensureSingleLine(` \n ${result}`)).toBe(result);
+ });
+
+ it('should remove newlines at the end of the string', () => {
+ const result = 'Test';
+
+ expect(ensureSingleLine(`${result}\n`)).toBe(result);
+ expect(ensureSingleLine(`${result}\t\n\t`)).toBe(result);
+ expect(ensureSingleLine(`${result}\r\n`)).toBe(result);
+ expect(ensureSingleLine(`${result}\r`)).toBe(result);
+ expect(ensureSingleLine(`${result} \r`)).toBe(result);
+ expect(ensureSingleLine(`${result} \r\n `)).toBe(result);
+ });
+
+ it('should replace newlines in the middle of the string with a single space', () => {
+ const result = 'Test';
+
+ expect(ensureSingleLine(`${result}\n${result}`)).toBe(`${result} ${result}`);
+ expect(ensureSingleLine(`${result}\t\n\t${result}`)).toBe(`${result} ${result}`);
+ expect(ensureSingleLine(`${result}\r\n${result}`)).toBe(`${result} ${result}`);
+ expect(ensureSingleLine(`${result}\r${result}`)).toBe(`${result} ${result}`);
+ expect(ensureSingleLine(`${result} \r${result}`)).toBe(`${result} ${result}`);
+ expect(ensureSingleLine(`${result} \r\n ${result}`)).toBe(`${result} ${result}`);
+ });
+ });
+});
diff --git a/spec/frontend/locale/sprintf_spec.js b/spec/frontend/locale/sprintf_spec.js
new file mode 100644
index 00000000000..52e903b819f
--- /dev/null
+++ b/spec/frontend/locale/sprintf_spec.js
@@ -0,0 +1,74 @@
+import sprintf from '~/locale/sprintf';
+
+describe('locale', () => {
+ describe('sprintf', () => {
+ it('does not modify string without parameters', () => {
+ const input = 'No parameters';
+
+ const output = sprintf(input);
+
+ expect(output).toBe(input);
+ });
+
+ it('ignores extraneous parameters', () => {
+ const input = 'No parameters';
+
+ const output = sprintf(input, { ignore: 'this' });
+
+ expect(output).toBe(input);
+ });
+
+ it('ignores extraneous placeholders', () => {
+ const input = 'No %{parameters}';
+
+ const output = sprintf(input);
+
+ expect(output).toBe(input);
+ });
+
+ it('replaces parameters', () => {
+ const input = '%{name} has %{count} parameters';
+ const parameters = {
+ name: 'this',
+ count: 2,
+ };
+
+ const output = sprintf(input, parameters);
+
+ expect(output).toBe('this has 2 parameters');
+ });
+
+ it('replaces multiple occurrences', () => {
+ const input = 'to %{verb} or not to %{verb}';
+ const parameters = {
+ verb: 'be',
+ };
+
+ const output = sprintf(input, parameters);
+
+ expect(output).toBe('to be or not to be');
+ });
+
+ it('escapes parameters', () => {
+ const input = 'contains %{userContent}';
+ const parameters = {
+ userContent: '<script>alert("malicious!")</script>',
+ };
+
+ const output = sprintf(input, parameters);
+
+ expect(output).toBe('contains &lt;script&gt;alert(&quot;malicious!&quot;)&lt;/script&gt;');
+ });
+
+ it('does not escape parameters for escapeParameters = false', () => {
+ const input = 'contains %{safeContent}';
+ const parameters = {
+ safeContent: '<strong>bold attempt</strong>',
+ };
+
+ const output = sprintf(input, parameters, false);
+
+ expect(output).toBe('contains <strong>bold attempt</strong>');
+ });
+ });
+});
diff --git a/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap
new file mode 100644
index 00000000000..5f9f13d591d
--- /dev/null
+++ b/spec/frontend/mr_popover/__snapshots__/mr_popover_spec.js.snap
@@ -0,0 +1,93 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MR Popover loaded state matches the snapshot 1`] = `
+<glpopover-stub
+ boundary="viewport"
+ placement="top"
+ show=""
+ target=""
+>
+ <div
+ class="mr-popover"
+ >
+ <div
+ class="d-flex align-items-center justify-content-between"
+ >
+ <div
+ class="d-inline-flex align-items-center"
+ >
+ <div
+ class="issuable-status-box status-box status-box-open"
+ >
+
+ Open
+
+ </div>
+
+ <span
+ class="text-secondary"
+ >
+ Opened
+ <time>
+ just now
+ </time>
+ </span>
+ </div>
+
+ <ciicon-stub
+ cssclasses=""
+ size="16"
+ status="[object Object]"
+ />
+ </div>
+
+ <h5
+ class="my-2"
+ >
+ MR Title
+ </h5>
+
+ <div
+ class="text-secondary"
+ >
+
+ foo/bar!1
+
+ </div>
+ </div>
+</glpopover-stub>
+`;
+
+exports[`MR Popover shows skeleton-loader while apollo is loading 1`] = `
+<glpopover-stub
+ boundary="viewport"
+ placement="top"
+ show=""
+ target=""
+>
+ <div
+ class="mr-popover"
+ >
+ <div>
+ <glskeletonloading-stub
+ class="animation-container-small mt-1"
+ lines="1"
+ />
+ </div>
+
+ <h5
+ class="my-2"
+ >
+ MR Title
+ </h5>
+
+ <div
+ class="text-secondary"
+ >
+
+ foo/bar!1
+
+ </div>
+ </div>
+</glpopover-stub>
+`;
diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js
new file mode 100644
index 00000000000..79ed4163010
--- /dev/null
+++ b/spec/frontend/mr_popover/mr_popover_spec.js
@@ -0,0 +1,61 @@
+import MRPopover from '~/mr_popover/components/mr_popover';
+import { shallowMount } from '@vue/test-utils';
+
+describe('MR Popover', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(MRPopover, {
+ propsData: {
+ target: document.createElement('a'),
+ projectPath: 'foo/bar',
+ mergeRequestIID: '1',
+ mergeRequestTitle: 'MR Title',
+ },
+ mocks: {
+ $apollo: {
+ loading: false,
+ },
+ },
+ });
+ });
+
+ it('shows skeleton-loader while apollo is loading', () => {
+ wrapper.vm.$apollo.loading = true;
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ describe('loaded state', () => {
+ it('matches the snapshot', () => {
+ wrapper.setData({
+ mergeRequest: {
+ state: 'opened',
+ createdAt: new Date(),
+ headPipeline: {
+ detailedStatus: {
+ group: 'success',
+ status: 'status_success',
+ },
+ },
+ },
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('does not show CI Icon if there is no pipeline data', () => {
+ wrapper.setData({
+ mergeRequest: {
+ state: 'opened',
+ headPipeline: null,
+ stateHumanName: 'Open',
+ title: 'Merge Request Title',
+ createdAt: new Date(),
+ },
+ });
+
+ expect(wrapper.contains('ciicon-stub')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/notebook/lib/highlight_spec.js b/spec/frontend/notebook/lib/highlight_spec.js
new file mode 100644
index 00000000000..d71c5718858
--- /dev/null
+++ b/spec/frontend/notebook/lib/highlight_spec.js
@@ -0,0 +1,15 @@
+import Prism from '~/notebook/lib/highlight';
+
+describe('Highlight library', () => {
+ it('imports python language', () => {
+ expect(Prism.languages.python).toBeDefined();
+ });
+
+ it('uses custom CSS classes', () => {
+ const el = document.createElement('div');
+ el.innerHTML = Prism.highlight('console.log("a");', Prism.languages.javascript);
+
+ expect(el.querySelector('.s')).not.toBeNull();
+ expect(el.querySelector('.nf')).not.toBeNull();
+ });
+});
diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
new file mode 100644
index 00000000000..07a366cf339
--- /dev/null
+++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
@@ -0,0 +1,34 @@
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+const localVue = createLocalVue();
+
+describe('ReplyPlaceholder', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = shallowMount(ReplyPlaceholder, {
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('emits onClick even on button click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(wrapper.emitted()).toEqual({
+ onClick: [[]],
+ });
+ });
+
+ it('should render reply button', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ expect(button.text()).toEqual('Reply...');
+ });
+});
diff --git a/spec/frontend/notes/components/discussion_resolve_button_spec.js b/spec/frontend/notes/components/discussion_resolve_button_spec.js
new file mode 100644
index 00000000000..5024f40ec5d
--- /dev/null
+++ b/spec/frontend/notes/components/discussion_resolve_button_spec.js
@@ -0,0 +1,74 @@
+import resolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+
+const buttonTitle = 'Resolve discussion';
+
+describe('resolveDiscussionButton', () => {
+ let wrapper;
+ let localVue;
+
+ const factory = options => {
+ localVue = createLocalVue();
+ wrapper = shallowMount(resolveDiscussionButton, {
+ localVue,
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ factory({
+ propsData: {
+ isResolving: false,
+ buttonTitle,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should emit a onClick event on button click', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ button.trigger('click');
+
+ expect(wrapper.emitted()).toEqual({
+ onClick: [[]],
+ });
+ });
+
+ it('should contain the provided button title', () => {
+ const button = wrapper.find({ ref: 'button' });
+
+ expect(button.text()).toContain(buttonTitle);
+ });
+
+ it('should show a loading spinner while resolving', () => {
+ factory({
+ propsData: {
+ isResolving: true,
+ buttonTitle,
+ },
+ });
+
+ const button = wrapper.find({ ref: 'isResolvingIcon' });
+
+ expect(button.exists()).toEqual(true);
+ });
+
+ it('should only show a loading spinner while resolving', () => {
+ factory({
+ propsData: {
+ isResolving: false,
+ buttonTitle,
+ },
+ });
+
+ const button = wrapper.find({ ref: 'isResolvingIcon' });
+
+ localVue.nextTick(() => {
+ expect(button.exists()).toEqual(false);
+ });
+ });
+});
diff --git a/spec/frontend/notes/components/note_attachment_spec.js b/spec/frontend/notes/components/note_attachment_spec.js
new file mode 100644
index 00000000000..b14a518b622
--- /dev/null
+++ b/spec/frontend/notes/components/note_attachment_spec.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import noteAttachment from '~/notes/components/note_attachment.vue';
+
+describe('issue note attachment', () => {
+ it('should render properly', () => {
+ const props = {
+ attachment: {
+ filename: 'dk.png',
+ image: true,
+ url: '/dk.png',
+ },
+ };
+
+ const Component = Vue.extend(noteAttachment);
+ const vm = new Component({
+ propsData: props,
+ }).$mount();
+
+ expect(vm.$el.classList.contains('note-attachment')).toBeTruthy();
+ expect(vm.$el.querySelector('img').src).toContain(props.attachment.url);
+ expect(vm.$el.querySelector('a').href).toContain(props.attachment.url);
+ });
+});
diff --git a/spec/frontend/notes/components/note_edited_text_spec.js b/spec/frontend/notes/components/note_edited_text_spec.js
new file mode 100644
index 00000000000..e4c8d954d50
--- /dev/null
+++ b/spec/frontend/notes/components/note_edited_text_spec.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import noteEditedText from '~/notes/components/note_edited_text.vue';
+
+describe('note_edited_text', () => {
+ let vm;
+ let props;
+
+ beforeEach(() => {
+ const Component = Vue.extend(noteEditedText);
+ props = {
+ actionText: 'Edited',
+ className: 'foo-bar',
+ editedAt: '2017-08-04T09:52:31.062Z',
+ editedBy: {
+ avatar_url: 'path',
+ id: 1,
+ name: 'Root',
+ path: '/root',
+ state: 'active',
+ username: 'root',
+ },
+ };
+
+ vm = new Component({
+ propsData: props,
+ }).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render block with provided className', () => {
+ expect(vm.$el.className).toEqual(props.className);
+ });
+
+ it('should render provided actionText', () => {
+ expect(vm.$el.textContent).toContain(props.actionText);
+ });
+
+ it('should render provided user information', () => {
+ const authorLink = vm.$el.querySelector('.js-user-link');
+
+ expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
+ expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
+ });
+});
diff --git a/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
new file mode 100644
index 00000000000..1e0bc708c31
--- /dev/null
+++ b/spec/frontend/pages/admin/abuse_reports/abuse_reports_spec.js
@@ -0,0 +1,46 @@
+import $ from 'jquery';
+import '~/lib/utils/text_utility';
+import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports';
+
+describe('Abuse Reports', () => {
+ const FIXTURE = 'abuse_reports/abuse_reports_list.html';
+ const MAX_MESSAGE_LENGTH = 500;
+
+ let $messages;
+
+ const assertMaxLength = $message => {
+ expect($message.text().length).toEqual(MAX_MESSAGE_LENGTH);
+ };
+ const findMessage = searchText =>
+ $messages.filter((index, element) => element.innerText.indexOf(searchText) > -1).first();
+
+ preloadFixtures(FIXTURE);
+
+ beforeEach(() => {
+ loadFixtures(FIXTURE);
+ new AbuseReports(); // eslint-disable-line no-new
+ $messages = $('.abuse-reports .message');
+ });
+
+ it('should truncate long messages', () => {
+ const $longMessage = findMessage('LONG MESSAGE');
+
+ expect($longMessage.data('originalMessage')).toEqual(jasmine.anything());
+ assertMaxLength($longMessage);
+ });
+
+ it('should not truncate short messages', () => {
+ const $shortMessage = findMessage('SHORT MESSAGE');
+
+ expect($shortMessage.data('originalMessage')).not.toEqual(jasmine.anything());
+ });
+
+ it('should allow clicking a truncated message to expand and collapse the full message', () => {
+ const $longMessage = findMessage('LONG MESSAGE');
+ $longMessage.click();
+
+ expect($longMessage.data('originalMessage').length).toEqual($longMessage.text().length);
+ $longMessage.click();
+ assertMaxLength($longMessage);
+ });
+});
diff --git a/spec/frontend/performance_bar/services/performance_bar_service_spec.js b/spec/frontend/performance_bar/services/performance_bar_service_spec.js
new file mode 100644
index 00000000000..cfec4b779e4
--- /dev/null
+++ b/spec/frontend/performance_bar/services/performance_bar_service_spec.js
@@ -0,0 +1,68 @@
+import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
+
+describe('PerformanceBarService', () => {
+ describe('callbackParams', () => {
+ describe('fireCallback', () => {
+ function fireCallback(response, peekUrl) {
+ return PerformanceBarService.callbackParams(response, peekUrl)[0];
+ }
+
+ it('returns false when the request URL is the peek URL', () => {
+ expect(
+ fireCallback({ headers: { 'x-request-id': '123' }, url: '/peek' }, '/peek'),
+ ).toBeFalsy();
+ });
+
+ it('returns false when there is no request ID', () => {
+ expect(fireCallback({ headers: {}, url: '/request' }, '/peek')).toBeFalsy();
+ });
+
+ it('returns false when the request is an API request', () => {
+ expect(
+ fireCallback({ headers: { 'x-request-id': '123' }, url: '/api/' }, '/peek'),
+ ).toBeFalsy();
+ });
+
+ it('returns false when the response is from the cache', () => {
+ expect(
+ fireCallback(
+ { headers: { 'x-request-id': '123', 'x-gitlab-from-cache': 'true' }, url: '/request' },
+ '/peek',
+ ),
+ ).toBeFalsy();
+ });
+
+ it('returns true when all conditions are met', () => {
+ expect(
+ fireCallback({ headers: { 'x-request-id': '123' }, url: '/request' }, '/peek'),
+ ).toBeTruthy();
+ });
+ });
+
+ describe('requestId', () => {
+ function requestId(response, peekUrl) {
+ return PerformanceBarService.callbackParams(response, peekUrl)[1];
+ }
+
+ it('gets the request ID from the headers', () => {
+ expect(requestId({ headers: { 'x-request-id': '123' } }, '/peek')).toEqual('123');
+ });
+ });
+
+ describe('requestUrl', () => {
+ function requestUrl(response, peekUrl) {
+ return PerformanceBarService.callbackParams(response, peekUrl)[2];
+ }
+
+ it('gets the request URL from the response object', () => {
+ expect(requestUrl({ headers: {}, url: '/request' }, '/peek')).toEqual('/request');
+ });
+
+ it('gets the request URL from response.config if present', () => {
+ expect(
+ requestUrl({ headers: {}, config: { url: '/config-url' }, url: '/request' }, '/peek'),
+ ).toEqual('/config-url');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/blank_state_spec.js b/spec/frontend/pipelines/blank_state_spec.js
new file mode 100644
index 00000000000..033bd5ccb73
--- /dev/null
+++ b/spec/frontend/pipelines/blank_state_spec.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import component from '~/pipelines/components/blank_state.vue';
+import mountComponent from '../helpers/vue_mount_component_helper';
+
+describe('Pipelines Blank State', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(component);
+
+ vm = mountComponent(Component, {
+ svgPath: 'foo',
+ message: 'Blank State',
+ });
+ });
+
+ it('should render svg', () => {
+ expect(vm.$el.querySelector('.svg-content img').getAttribute('src')).toEqual('foo');
+ });
+
+ it('should render message', () => {
+ expect(vm.$el.querySelector('h4').textContent.trim()).toEqual('Blank State');
+ });
+});
diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js
new file mode 100644
index 00000000000..f12950b8fce
--- /dev/null
+++ b/spec/frontend/pipelines/empty_state_spec.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import emptyStateComp from '~/pipelines/components/empty_state.vue';
+import mountComponent from '../helpers/vue_mount_component_helper';
+
+describe('Pipelines Empty State', () => {
+ let component;
+ let EmptyStateComponent;
+
+ beforeEach(() => {
+ EmptyStateComponent = Vue.extend(emptyStateComp);
+
+ component = mountComponent(EmptyStateComponent, {
+ helpPagePath: 'foo',
+ emptyStateSvgPath: 'foo',
+ canSetCi: true,
+ });
+ });
+
+ afterEach(() => {
+ component.$destroy();
+ });
+
+ it('should render empty state SVG', () => {
+ expect(component.$el.querySelector('.svg-content svg')).toBeDefined();
+ });
+
+ it('should render empty state information', () => {
+ expect(component.$el.querySelector('h4').textContent).toContain('Build with confidence');
+
+ expect(
+ component.$el
+ .querySelector('p')
+ .innerHTML.trim()
+ .replace(/\n+\s+/m, ' ')
+ .replace(/\s\s+/g, ' '),
+ ).toContain('Continuous Integration can help catch bugs by running your tests automatically,');
+
+ expect(
+ component.$el
+ .querySelector('p')
+ .innerHTML.trim()
+ .replace(/\n+\s+/m, ' ')
+ .replace(/\s\s+/g, ' '),
+ ).toContain(
+ 'while Continuous Deployment can help you deliver code to your product environment',
+ );
+ });
+
+ it('should render a link with provided help path', () => {
+ expect(component.$el.querySelector('.js-get-started-pipelines').getAttribute('href')).toEqual(
+ 'foo',
+ );
+
+ expect(component.$el.querySelector('.js-get-started-pipelines').textContent).toContain(
+ 'Get started with Pipelines',
+ );
+ });
+});
diff --git a/spec/frontend/pipelines/pipeline_store_spec.js b/spec/frontend/pipelines/pipeline_store_spec.js
new file mode 100644
index 00000000000..1d5754d1f05
--- /dev/null
+++ b/spec/frontend/pipelines/pipeline_store_spec.js
@@ -0,0 +1,27 @@
+import PipelineStore from '~/pipelines/stores/pipeline_store';
+
+describe('Pipeline Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new PipelineStore();
+ });
+
+ it('should set defaults', () => {
+ expect(store.state.pipeline).toEqual({});
+ });
+
+ describe('storePipeline', () => {
+ it('should store empty object if none is provided', () => {
+ store.storePipeline();
+
+ expect(store.state.pipeline).toEqual({});
+ });
+
+ it('should store received object', () => {
+ store.storePipeline({ foo: 'bar' });
+
+ expect(store.state.pipeline).toEqual({ foo: 'bar' });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_store_spec.js b/spec/frontend/pipelines/pipelines_store_spec.js
new file mode 100644
index 00000000000..ce21f788ed5
--- /dev/null
+++ b/spec/frontend/pipelines/pipelines_store_spec.js
@@ -0,0 +1,77 @@
+import PipelineStore from '~/pipelines/stores/pipelines_store';
+
+describe('Pipelines Store', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new PipelineStore();
+ });
+
+ it('should be initialized with an empty state', () => {
+ expect(store.state.pipelines).toEqual([]);
+ expect(store.state.count).toEqual({});
+ expect(store.state.pageInfo).toEqual({});
+ });
+
+ describe('storePipelines', () => {
+ it('should use the default parameter if none is provided', () => {
+ store.storePipelines();
+
+ expect(store.state.pipelines).toEqual([]);
+ });
+
+ it('should store the provided array', () => {
+ const array = [{ id: 1, status: 'running' }, { id: 2, status: 'success' }];
+ store.storePipelines(array);
+
+ expect(store.state.pipelines).toEqual(array);
+ });
+ });
+
+ describe('storeCount', () => {
+ it('should use the default parameter if none is provided', () => {
+ store.storeCount();
+
+ expect(store.state.count).toEqual({});
+ });
+
+ it('should store the provided count', () => {
+ const count = { all: 20, finished: 10 };
+ store.storeCount(count);
+
+ expect(store.state.count).toEqual(count);
+ });
+ });
+
+ describe('storePagination', () => {
+ it('should use the default parameter if none is provided', () => {
+ store.storePagination();
+
+ expect(store.state.pageInfo).toEqual({});
+ });
+
+ it('should store pagination information normalized and parsed', () => {
+ const pagination = {
+ 'X-nExt-pAge': '2',
+ 'X-page': '1',
+ 'X-Per-Page': '1',
+ 'X-Prev-Page': '2',
+ 'X-TOTAL': '37',
+ 'X-Total-Pages': '2',
+ };
+
+ const expectedResult = {
+ perPage: 1,
+ page: 1,
+ total: 37,
+ totalPages: 2,
+ nextPage: 2,
+ previousPage: 2,
+ };
+
+ store.storePagination(pagination);
+
+ expect(store.state.pageInfo).toEqual(expectedResult);
+ });
+ });
+});
diff --git a/spec/frontend/registry/getters_spec.js b/spec/frontend/registry/getters_spec.js
new file mode 100644
index 00000000000..839aa718997
--- /dev/null
+++ b/spec/frontend/registry/getters_spec.js
@@ -0,0 +1,46 @@
+import * as getters from '~/registry/stores/getters';
+
+describe('Getters Registry Store', () => {
+ let state;
+
+ beforeEach(() => {
+ state = {
+ isLoading: false,
+ endpoint: '/root/empty-project/container_registry.json',
+ repos: [
+ {
+ canDelete: true,
+ destroyPath: 'bar',
+ id: '134',
+ isLoading: false,
+ list: [],
+ location: 'foo',
+ name: 'gitlab-org/omnibus-gitlab/foo',
+ tagsPath: 'foo',
+ },
+ {
+ canDelete: true,
+ destroyPath: 'bar',
+ id: '123',
+ isLoading: false,
+ list: [],
+ location: 'foo',
+ name: 'gitlab-org/omnibus-gitlab',
+ tagsPath: 'foo',
+ },
+ ],
+ };
+ });
+
+ describe('isLoading', () => {
+ it('should return the isLoading property', () => {
+ expect(getters.isLoading(state)).toEqual(state.isLoading);
+ });
+ });
+
+ describe('repos', () => {
+ it('should return the repos', () => {
+ expect(getters.repos(state)).toEqual(state.repos);
+ });
+ });
+});
diff --git a/spec/frontend/reports/components/report_link_spec.js b/spec/frontend/reports/components/report_link_spec.js
new file mode 100644
index 00000000000..f879899e9c5
--- /dev/null
+++ b/spec/frontend/reports/components/report_link_spec.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import component from '~/reports/components/report_link.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('report link', () => {
+ let vm;
+
+ const Component = Vue.extend(component);
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('With url', () => {
+ it('renders link', () => {
+ vm = mountComponent(Component, {
+ issue: {
+ path: 'Gemfile.lock',
+ urlPath: '/Gemfile.lock',
+ },
+ });
+
+ expect(vm.$el.textContent.trim()).toContain('in');
+ expect(vm.$el.querySelector('a').getAttribute('href')).toEqual('/Gemfile.lock');
+ expect(vm.$el.querySelector('a').textContent.trim()).toEqual('Gemfile.lock');
+ });
+ });
+
+ describe('Without url', () => {
+ it('does not render link', () => {
+ vm = mountComponent(Component, {
+ issue: {
+ path: 'Gemfile.lock',
+ },
+ });
+
+ expect(vm.$el.querySelector('a')).toBeNull();
+ expect(vm.$el.textContent.trim()).toContain('in');
+ expect(vm.$el.textContent.trim()).toContain('Gemfile.lock');
+ });
+ });
+
+ describe('with line', () => {
+ it('renders line number', () => {
+ vm = mountComponent(Component, {
+ issue: {
+ path: 'Gemfile.lock',
+ urlPath: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
+ line: 22,
+ },
+ });
+
+ expect(vm.$el.querySelector('a').textContent.trim()).toContain('Gemfile.lock:22');
+ });
+ });
+
+ describe('without line', () => {
+ it('does not render line number', () => {
+ vm = mountComponent(Component, {
+ issue: {
+ path: 'Gemfile.lock',
+ urlPath: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
+ },
+ });
+
+ expect(vm.$el.querySelector('a').textContent.trim()).not.toContain(':22');
+ });
+ });
+});
diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/store/utils_spec.js
new file mode 100644
index 00000000000..1679d120db2
--- /dev/null
+++ b/spec/frontend/reports/store/utils_spec.js
@@ -0,0 +1,138 @@
+import * as utils from '~/reports/store/utils';
+import {
+ STATUS_FAILED,
+ STATUS_SUCCESS,
+ ICON_WARNING,
+ ICON_SUCCESS,
+ ICON_NOTFOUND,
+} from '~/reports/constants';
+
+describe('Reports store utils', () => {
+ describe('summaryTextbuilder', () => {
+ it('should render text for no changed results in multiple tests', () => {
+ const name = 'Test summary';
+ const data = { total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe('Test summary contained no changed test results out of 10 total tests');
+ });
+
+ it('should render text for no changed results in one test', () => {
+ const name = 'Test summary';
+ const data = { total: 1 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe('Test summary contained no changed test results out of 1 total test');
+ });
+
+ it('should render text for multiple failed results', () => {
+ const name = 'Test summary';
+ const data = { failed: 3, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe('Test summary contained 3 failed test results out of 10 total tests');
+ });
+
+ it('should render text for multiple fixed results', () => {
+ const name = 'Test summary';
+ const data = { resolved: 4, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe('Test summary contained 4 fixed test results out of 10 total tests');
+ });
+
+ it('should render text for multiple fixed, and multiple failed results', () => {
+ const name = 'Test summary';
+ const data = { failed: 3, resolved: 4, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary contained 3 failed test results and 4 fixed test results out of 10 total tests',
+ );
+ });
+
+ it('should render text for a singular fixed, and a singular failed result', () => {
+ const name = 'Test summary';
+ const data = { failed: 1, resolved: 1, total: 10 };
+ const result = utils.summaryTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Test summary contained 1 failed test result and 1 fixed test result out of 10 total tests',
+ );
+ });
+ });
+
+ describe('reportTextBuilder', () => {
+ it('should render text for no changed results in multiple tests', () => {
+ const name = 'Rspec';
+ const data = { total: 10 };
+ const result = utils.reportTextBuilder(name, data);
+
+ expect(result).toBe('Rspec found no changed test results out of 10 total tests');
+ });
+
+ it('should render text for no changed results in one test', () => {
+ const name = 'Rspec';
+ const data = { total: 1 };
+ const result = utils.reportTextBuilder(name, data);
+
+ expect(result).toBe('Rspec found no changed test results out of 1 total test');
+ });
+
+ it('should render text for multiple failed results', () => {
+ const name = 'Rspec';
+ const data = { failed: 3, total: 10 };
+ const result = utils.reportTextBuilder(name, data);
+
+ expect(result).toBe('Rspec found 3 failed test results out of 10 total tests');
+ });
+
+ it('should render text for multiple fixed results', () => {
+ const name = 'Rspec';
+ const data = { resolved: 4, total: 10 };
+ const result = utils.reportTextBuilder(name, data);
+
+ expect(result).toBe('Rspec found 4 fixed test results out of 10 total tests');
+ });
+
+ it('should render text for multiple fixed, and multiple failed results', () => {
+ const name = 'Rspec';
+ const data = { failed: 3, resolved: 4, total: 10 };
+ const result = utils.reportTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Rspec found 3 failed test results and 4 fixed test results out of 10 total tests',
+ );
+ });
+
+ it('should render text for a singular fixed, and a singular failed result', () => {
+ const name = 'Rspec';
+ const data = { failed: 1, resolved: 1, total: 10 };
+ const result = utils.reportTextBuilder(name, data);
+
+ expect(result).toBe(
+ 'Rspec found 1 failed test result and 1 fixed test result out of 10 total tests',
+ );
+ });
+ });
+
+ describe('statusIcon', () => {
+ describe('with failed status', () => {
+ it('returns ICON_WARNING', () => {
+ expect(utils.statusIcon(STATUS_FAILED)).toEqual(ICON_WARNING);
+ });
+ });
+
+ describe('with success status', () => {
+ it('returns ICON_SUCCESS', () => {
+ expect(utils.statusIcon(STATUS_SUCCESS)).toEqual(ICON_SUCCESS);
+ });
+ });
+
+ describe('without a status', () => {
+ it('returns ICON_NOTFOUND', () => {
+ expect(utils.statusIcon()).toEqual(ICON_NOTFOUND);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/confidential_edit_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_buttons_spec.js
new file mode 100644
index 00000000000..32da9f83112
--- /dev/null
+++ b/spec/frontend/sidebar/confidential_edit_buttons_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import editFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
+
+describe('Edit Form Buttons', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(editFormButtons);
+ const toggleForm = () => {};
+ const updateConfidentialAttribute = () => {};
+
+ vm1 = new Component({
+ propsData: {
+ isConfidential: true,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isConfidential: false,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+ });
+
+ it('renders on or off text based on confidentiality', () => {
+ expect(vm1.$el.innerHTML.includes('Turn Off')).toBe(true);
+
+ expect(vm2.$el.innerHTML.includes('Turn On')).toBe(true);
+ });
+});
diff --git a/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js
new file mode 100644
index 00000000000..369088cb258
--- /dev/null
+++ b/spec/frontend/sidebar/confidential_edit_form_buttons_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import editForm from '~/sidebar/components/confidential/edit_form.vue';
+
+describe('Edit Form Dropdown', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(editForm);
+ const toggleForm = () => {};
+ const updateConfidentialAttribute = () => {};
+
+ vm1 = new Component({
+ propsData: {
+ isConfidential: true,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isConfidential: false,
+ toggleForm,
+ updateConfidentialAttribute,
+ },
+ }).$mount();
+ });
+
+ it('renders on the appropriate warning text', () => {
+ expect(vm1.$el.innerHTML.includes('You are going to turn off the confidentiality.')).toBe(true);
+
+ expect(vm2.$el.innerHTML.includes('You are going to turn on the confidentiality.')).toBe(true);
+ });
+});
diff --git a/spec/frontend/sidebar/lock/edit_form_spec.js b/spec/frontend/sidebar/lock/edit_form_spec.js
new file mode 100644
index 00000000000..ec10a999a40
--- /dev/null
+++ b/spec/frontend/sidebar/lock/edit_form_spec.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import editForm from '~/sidebar/components/lock/edit_form.vue';
+
+describe('EditForm', () => {
+ let vm1;
+ let vm2;
+
+ beforeEach(() => {
+ const Component = Vue.extend(editForm);
+ const toggleForm = () => {};
+ const updateLockedAttribute = () => {};
+
+ vm1 = new Component({
+ propsData: {
+ isLocked: true,
+ toggleForm,
+ updateLockedAttribute,
+ issuableType: 'issue',
+ },
+ }).$mount();
+
+ vm2 = new Component({
+ propsData: {
+ isLocked: false,
+ toggleForm,
+ updateLockedAttribute,
+ issuableType: 'merge_request',
+ },
+ }).$mount();
+ });
+
+ it('renders on the appropriate warning text', () => {
+ expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true);
+
+ expect(vm2.$el.innerHTML.includes('Lock this merge request?')).toBe(true);
+ });
+});
diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js
index 8c36d8ff49f..1e3c28e25eb 100644
--- a/spec/frontend/test_setup.js
+++ b/spec/frontend/test_setup.js
@@ -2,6 +2,12 @@ import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import axios from '~/lib/utils/axios_utils';
import { initializeTestTimeout } from './helpers/timeout';
+import { getJSONFixture, loadHTMLFixture, setHTMLFixture } from './helpers/fixtures';
+
+// wait for pending setTimeout()s
+afterEach(() => {
+ jest.runAllTimers();
+});
initializeTestTimeout(300);
@@ -17,4 +23,24 @@ beforeEach(done => {
done();
});
+Vue.config.devtools = false;
+Vue.config.productionTip = false;
+
Vue.use(Translate);
+
+// workaround for JSDOM not supporting innerText
+// see https://github.com/jsdom/jsdom/issues/1245
+Object.defineProperty(global.Element.prototype, 'innerText', {
+ get() {
+ return this.textContent;
+ },
+ configurable: true, // make it so that it doesn't blow chunks on re-running tests with things like --watch
+});
+
+// convenience wrapper for migration from Karma
+Object.assign(global, {
+ loadFixtures: loadHTMLFixture,
+ loadJSONFixtures: getJSONFixture,
+ preloadFixtures() {},
+ setFixtures: setHTMLFixture,
+});
diff --git a/spec/frontend/u2f/util_spec.js b/spec/frontend/u2f/util_spec.js
new file mode 100644
index 00000000000..32cd6891384
--- /dev/null
+++ b/spec/frontend/u2f/util_spec.js
@@ -0,0 +1,61 @@
+import { canInjectU2fApi } from '~/u2f/util';
+
+describe('U2F Utils', () => {
+ describe('canInjectU2fApi', () => {
+ it('returns false for Chrome < 41', () => {
+ const userAgent =
+ 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.28 Safari/537.36';
+
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns true for Chrome >= 41', () => {
+ const userAgent =
+ 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36';
+
+ expect(canInjectU2fApi(userAgent)).toBe(true);
+ });
+
+ it('returns false for Opera < 40', () => {
+ const userAgent =
+ 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36 OPR/32.0.1948.25';
+
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns true for Opera >= 40', () => {
+ const userAgent =
+ 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36 OPR/43.0.2442.991';
+
+ expect(canInjectU2fApi(userAgent)).toBe(true);
+ });
+
+ it('returns false for Safari', () => {
+ const userAgent =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/603.2.4 (KHTML, like Gecko) Version/10.1.1 Safari/603.2.4';
+
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns false for Chrome on Android', () => {
+ const userAgent =
+ 'Mozilla/5.0 (Linux; Android 7.0; VS988 Build/NRD90U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3145.0 Mobile Safari/537.36';
+
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns false for Chrome on iOS', () => {
+ const userAgent =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1';
+
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+
+ it('returns false for Safari on iOS', () => {
+ const userAgent =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A356 Safari/604.1';
+
+ expect(canInjectU2fApi(userAgent)).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js
new file mode 100644
index 00000000000..16c8c939a6f
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js
@@ -0,0 +1,51 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import MrWidgetContainer from '~/vue_merge_request_widget/components/mr_widget_container.vue';
+
+const BODY_HTML = '<div class="test-body">Hello World</div>';
+const FOOTER_HTML = '<div class="test-footer">Goodbye!</div>';
+
+describe('MrWidgetContainer', () => {
+ let wrapper;
+
+ const factory = (options = {}) => {
+ const localVue = createLocalVue();
+
+ wrapper = shallowMount(localVue.extend(MrWidgetContainer), {
+ localVue,
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('has layout', () => {
+ factory();
+
+ expect(wrapper.is('.mr-widget-heading')).toBe(true);
+ expect(wrapper.contains('.mr-widget-content')).toBe(true);
+ });
+
+ it('accepts default slot', () => {
+ factory({
+ slots: {
+ default: BODY_HTML,
+ },
+ });
+
+ expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true);
+ });
+
+ it('accepts footer slot', () => {
+ factory({
+ slots: {
+ default: BODY_HTML,
+ footer: FOOTER_HTML,
+ },
+ });
+
+ expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true);
+ expect(wrapper.contains('.test-footer')).toBe(true);
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js
new file mode 100644
index 00000000000..f7c2376eebf
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js
@@ -0,0 +1,30 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+const TEST_ICON = 'commit';
+
+describe('MrWidgetIcon', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ const localVue = createLocalVue();
+
+ wrapper = shallowMount(localVue.extend(MrWidgetIcon), {
+ propsData: {
+ name: TEST_ICON,
+ },
+ sync: false,
+ localVue,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders icon and container', () => {
+ expect(wrapper.is('.circle-icon-container')).toBe(true);
+ expect(wrapper.find(Icon).props('name')).toEqual(TEST_ICON);
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js
new file mode 100644
index 00000000000..994d6255324
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/states/commit_edit_spec.js
@@ -0,0 +1,85 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import CommitEdit from '~/vue_merge_request_widget/components/states/commit_edit.vue';
+
+const localVue = createLocalVue();
+const testCommitMessage = 'Test commit message';
+const testLabel = 'Test label';
+const testInputId = 'test-input-id';
+
+describe('Commits edit component', () => {
+ let wrapper;
+
+ const createComponent = (slots = {}) => {
+ wrapper = shallowMount(localVue.extend(CommitEdit), {
+ localVue,
+ sync: false,
+ propsData: {
+ value: testCommitMessage,
+ label: testLabel,
+ inputId: testInputId,
+ },
+ slots: {
+ ...slots,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findTextarea = () => wrapper.find('.form-control');
+
+ it('has a correct label', () => {
+ const labelElement = wrapper.find('.col-form-label');
+
+ expect(labelElement.text()).toBe(testLabel);
+ });
+
+ describe('textarea', () => {
+ it('has a correct ID', () => {
+ expect(findTextarea().attributes('id')).toBe(testInputId);
+ });
+
+ it('has a correct value', () => {
+ expect(findTextarea().element.value).toBe(testCommitMessage);
+ });
+
+ it('emits an input event and receives changed value', () => {
+ const changedCommitMessage = 'Changed commit message';
+
+ findTextarea().element.value = changedCommitMessage;
+ findTextarea().trigger('input');
+
+ expect(wrapper.emitted().input[0]).toEqual([changedCommitMessage]);
+ expect(findTextarea().element.value).toBe(changedCommitMessage);
+ });
+ });
+
+ describe('when slots are present', () => {
+ beforeEach(() => {
+ createComponent({
+ header: `<div class="test-header">${testCommitMessage}</div>`,
+ checkbox: `<label slot="checkbox" class="test-checkbox">${testLabel}</label >`,
+ });
+ });
+
+ it('renders header slot correctly', () => {
+ const headerSlotElement = wrapper.find('.test-header');
+
+ expect(headerSlotElement.exists()).toBe(true);
+ expect(headerSlotElement.text()).toBe(testCommitMessage);
+ });
+
+ it('renders checkbox slot correctly', () => {
+ const checkboxSlotElement = wrapper.find('.test-checkbox');
+
+ expect(checkboxSlotElement.exists()).toBe(true);
+ expect(checkboxSlotElement.text()).toBe(testLabel);
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
new file mode 100644
index 00000000000..daf1cc2d98b
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
@@ -0,0 +1,61 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlDropdownItem } from '@gitlab/ui';
+import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
+
+const localVue = createLocalVue();
+const commits = [
+ {
+ title: 'Commit 1',
+ short_id: '78d5b7',
+ message: 'Update test.txt',
+ },
+ {
+ title: 'Commit 2',
+ short_id: '34cbe28b',
+ message: 'Fixed test',
+ },
+ {
+ title: 'Commit 3',
+ short_id: 'fa42932a',
+ message: 'Added changelog',
+ },
+];
+
+describe('Commits message dropdown component', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(localVue.extend(CommitMessageDropdown), {
+ localVue,
+ sync: false,
+ propsData: {
+ commits,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
+ const findFirstDropdownElement = () => findDropdownElements().at(0);
+
+ it('should have 3 elements in dropdown list', () => {
+ expect(findDropdownElements().length).toBe(3);
+ });
+
+ it('should have correct message for the first dropdown list element', () => {
+ expect(findFirstDropdownElement().text()).toBe('78d5b7 Commit 1');
+ });
+
+ it('should emit a commit title on selecting commit', () => {
+ findFirstDropdownElement().vm.$emit('click');
+
+ expect(wrapper.emitted().input[0]).toEqual(['Update test.txt']);
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
new file mode 100644
index 00000000000..9ee2f88c78d
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js
@@ -0,0 +1,136 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+const localVue = createLocalVue();
+
+describe('Commits header component', () => {
+ let wrapper;
+
+ const createComponent = props => {
+ wrapper = shallowMount(localVue.extend(CommitsHeader), {
+ localVue,
+ sync: false,
+ propsData: {
+ isSquashEnabled: false,
+ targetBranch: 'master',
+ commitsCount: 5,
+ isFastForwardEnabled: false,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count');
+ const findCommitToggle = () => wrapper.find('.commit-edit-toggle');
+ const findIcon = () => wrapper.find(Icon);
+ const findCommitsCountMessage = () => wrapper.find('.commits-count-message');
+ const findTargetBranchMessage = () => wrapper.find('.label-branch');
+ const findModifyButton = () => wrapper.find('.modify-message-button');
+
+ describe('when fast-forward is enabled', () => {
+ beforeEach(() => {
+ createComponent({
+ isFastForwardEnabled: true,
+ isSquashEnabled: true,
+ });
+ });
+
+ it('has commits count message showing 1 commit', () => {
+ expect(findCommitsCountMessage().text()).toBe('1 commit');
+ });
+
+ it('has button with modify commit message', () => {
+ expect(findModifyButton().text()).toBe('Modify commit message');
+ });
+
+ it('does not have merge commit part of the message', () => {
+ expect(findHeaderWrapper().text()).not.toContain('1 merge commit');
+ });
+ });
+
+ describe('when collapsed', () => {
+ it('toggle has aria-label equal to Expand', () => {
+ createComponent();
+
+ expect(findCommitToggle().attributes('aria-label')).toBe('Expand');
+ });
+
+ it('has a chevron-right icon', () => {
+ createComponent();
+ wrapper.setData({ expanded: false });
+
+ expect(findIcon().props('name')).toBe('chevron-right');
+ });
+
+ describe('when squash is disabled', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has commits count message showing correct amount of commits', () => {
+ expect(findCommitsCountMessage().text()).toBe('5 commits');
+ });
+
+ it('has button with modify merge commit message', () => {
+ expect(findModifyButton().text()).toBe('Modify merge commit');
+ });
+ });
+
+ describe('when squash is enabled', () => {
+ beforeEach(() => {
+ createComponent({ isSquashEnabled: true });
+ });
+
+ it('has commits count message showing one commit when squash is enabled', () => {
+ expect(findCommitsCountMessage().text()).toBe('1 commit');
+ });
+
+ it('has button with modify commit messages text', () => {
+ expect(findModifyButton().text()).toBe('Modify commit messages');
+ });
+ });
+
+ it('has correct target branch displayed', () => {
+ createComponent();
+
+ expect(findTargetBranchMessage().text()).toBe('master');
+ });
+
+ it('does has merge commit part of the message', () => {
+ expect(findHeaderWrapper().text()).toContain('1 merge commit');
+ });
+ });
+
+ describe('when expanded', () => {
+ beforeEach(() => {
+ createComponent();
+ wrapper.setData({ expanded: true });
+ });
+
+ it('toggle has aria-label equal to collapse', done => {
+ wrapper.vm.$nextTick(() => {
+ expect(findCommitToggle().attributes('aria-label')).toBe('Collapse');
+ done();
+ });
+ });
+
+ it('has a chevron-down icon', done => {
+ wrapper.vm.$nextTick(() => {
+ expect(findIcon().props('name')).toBe('chevron-down');
+ done();
+ });
+ });
+
+ it('has a collapse text', done => {
+ wrapper.vm.$nextTick(() => {
+ expect(findHeaderWrapper().text()).toBe('Collapse');
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
new file mode 100644
index 00000000000..b356ea85cad
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js
@@ -0,0 +1,103 @@
+import getStateKey from '~/vue_merge_request_widget/stores/get_state_key';
+
+describe('getStateKey', () => {
+ it('should return proper state name', () => {
+ const context = {
+ mergeStatus: 'checked',
+ mergeWhenPipelineSucceeds: false,
+ canMerge: true,
+ onlyAllowMergeIfPipelineSucceeds: false,
+ isPipelineFailed: false,
+ hasMergeableDiscussionsState: false,
+ isPipelineBlocked: false,
+ canBeMerged: false,
+ };
+ const data = {
+ project_archived: false,
+ branch_missing: false,
+ commits_count: 2,
+ has_conflicts: false,
+ work_in_progress: false,
+ };
+ const bound = getStateKey.bind(context, data);
+
+ expect(bound()).toEqual(null);
+
+ context.canBeMerged = true;
+
+ expect(bound()).toEqual('readyToMerge');
+
+ context.canMerge = false;
+
+ expect(bound()).toEqual('notAllowedToMerge');
+
+ context.mergeWhenPipelineSucceeds = true;
+
+ expect(bound()).toEqual('mergeWhenPipelineSucceeds');
+
+ context.isSHAMismatch = true;
+
+ expect(bound()).toEqual('shaMismatch');
+
+ context.isPipelineBlocked = true;
+
+ expect(bound()).toEqual('pipelineBlocked');
+
+ context.hasMergeableDiscussionsState = true;
+
+ expect(bound()).toEqual('unresolvedDiscussions');
+
+ context.onlyAllowMergeIfPipelineSucceeds = true;
+ context.isPipelineFailed = true;
+
+ expect(bound()).toEqual('pipelineFailed');
+
+ data.work_in_progress = true;
+
+ expect(bound()).toEqual('workInProgress');
+
+ data.has_conflicts = true;
+
+ expect(bound()).toEqual('conflicts');
+
+ context.mergeStatus = 'unchecked';
+
+ expect(bound()).toEqual('checking');
+
+ data.commits_count = 0;
+
+ expect(bound()).toEqual('nothingToMerge');
+
+ data.branch_missing = true;
+
+ expect(bound()).toEqual('missingBranch');
+
+ data.project_archived = true;
+
+ expect(bound()).toEqual('archived');
+ });
+
+ it('returns rebased state key', () => {
+ const context = {
+ mergeStatus: 'checked',
+ mergeWhenPipelineSucceeds: false,
+ canMerge: true,
+ onlyAllowMergeIfPipelineSucceeds: true,
+ isPipelineFailed: true,
+ hasMergeableDiscussionsState: false,
+ isPipelineBlocked: false,
+ canBeMerged: false,
+ shouldBeRebased: true,
+ };
+ const data = {
+ project_archived: false,
+ branch_missing: false,
+ commits_count: 2,
+ has_conflicts: false,
+ work_in_progress: false,
+ };
+ const bound = getStateKey.bind(context, data);
+
+ expect(bound()).toEqual('rebase');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/callout_spec.js b/spec/frontend/vue_shared/components/callout_spec.js
new file mode 100644
index 00000000000..91208dfb31a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/callout_spec.js
@@ -0,0 +1,66 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Callout from '~/vue_shared/components/callout.vue';
+
+const TEST_MESSAGE = 'This is a callout message!';
+const TEST_SLOT = '<button>This is a callout slot!</button>';
+
+const localVue = createLocalVue();
+
+describe('Callout Component', () => {
+ let wrapper;
+
+ const factory = options => {
+ wrapper = shallowMount(localVue.extend(Callout), {
+ localVue,
+ ...options,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('should render the appropriate variant of callout', () => {
+ factory({
+ propsData: {
+ category: 'info',
+ message: TEST_MESSAGE,
+ },
+ });
+
+ expect(wrapper.classes()).toEqual(['bs-callout', 'bs-callout-info']);
+
+ expect(wrapper.element.tagName).toEqual('DIV');
+ });
+
+ it('should render accessibility attributes', () => {
+ factory({
+ propsData: {
+ message: TEST_MESSAGE,
+ },
+ });
+
+ expect(wrapper.attributes('role')).toEqual('alert');
+ expect(wrapper.attributes('aria-live')).toEqual('assertive');
+ });
+
+ it('should render the provided message', () => {
+ factory({
+ propsData: {
+ message: TEST_MESSAGE,
+ },
+ });
+
+ expect(wrapper.element.innerHTML.trim()).toEqual(TEST_MESSAGE);
+ });
+
+ it('should render the provided slot', () => {
+ factory({
+ slots: {
+ default: TEST_SLOT,
+ },
+ });
+
+ expect(wrapper.element.innerHTML.trim()).toEqual(TEST_SLOT);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js
new file mode 100644
index 00000000000..6b91a20ff76
--- /dev/null
+++ b/spec/frontend/vue_shared/components/code_block_spec.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import component from '~/vue_shared/components/code_block.vue';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Code Block', () => {
+ const Component = Vue.extend(component);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a code block with the provided code', () => {
+ const code =
+ "Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'";
+
+ vm = mountComponent(Component, {
+ code,
+ });
+
+ expect(vm.$el.querySelector('code').textContent).toEqual(code);
+ });
+
+ it('escapes XSS injections', () => {
+ const code = 'CCC&lt;img src=x onerror=alert(document.domain)&gt;';
+
+ vm = mountComponent(Component, {
+ code,
+ });
+
+ expect(vm.$el.querySelector('code').textContent).toEqual(code);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js
new file mode 100644
index 00000000000..c4358f0d9cb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/mode_changed_spec.js
@@ -0,0 +1,23 @@
+import { shallowMount } from '@vue/test-utils';
+import ModeChanged from '~/vue_shared/components/diff_viewer/viewers/mode_changed.vue';
+
+describe('Diff viewer mode changed component', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = shallowMount(ModeChanged, {
+ propsData: {
+ aMode: '123',
+ bMode: '321',
+ },
+ });
+ });
+
+ afterEach(() => {
+ vm.destroy();
+ });
+
+ it('renders aMode & bMode', () => {
+ expect(vm.text()).toContain('File mode changed from 123 to 321');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js
new file mode 100644
index 00000000000..0b3dbb61c96
--- /dev/null
+++ b/spec/frontend/vue_shared/components/identicon_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import identiconComponent from '~/vue_shared/components/identicon.vue';
+
+const createComponent = sizeClass => {
+ const Component = Vue.extend(identiconComponent);
+
+ return new Component({
+ propsData: {
+ entityId: 1,
+ entityName: 'entity-name',
+ sizeClass,
+ },
+ }).$mount();
+};
+
+describe('IdenticonComponent', () => {
+ describe('computed', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('identiconBackgroundClass', () => {
+ it('should return bg class based on entityId', () => {
+ vm.entityId = 4;
+
+ expect(vm.identiconBackgroundClass).toBeDefined();
+ expect(vm.identiconBackgroundClass).toBe('bg5');
+ });
+ });
+
+ describe('identiconTitle', () => {
+ it('should return first letter of entity title in uppercase', () => {
+ vm.entityName = 'dummy-group';
+
+ expect(vm.identiconTitle).toBeDefined();
+ expect(vm.identiconTitle).toBe('D');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('should render identicon', () => {
+ const vm = createComponent();
+
+ expect(vm.$el.nodeName).toBe('DIV');
+ expect(vm.$el.classList.contains('identicon')).toBeTruthy();
+ expect(vm.$el.classList.contains('s40')).toBeTruthy();
+ expect(vm.$el.classList.contains('bg2')).toBeTruthy();
+ vm.$destroy();
+ });
+
+ it('should render identicon with provided sizing class', () => {
+ const vm = createComponent('s32');
+
+ expect(vm.$el.classList.contains('s32')).toBeTruthy();
+ vm.$destroy();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/lib/utils/dom_utils_spec.js b/spec/frontend/vue_shared/components/lib/utils/dom_utils_spec.js
new file mode 100644
index 00000000000..2388660b0c2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/lib/utils/dom_utils_spec.js
@@ -0,0 +1,13 @@
+import * as domUtils from '~/vue_shared/components/lib/utils/dom_utils';
+
+describe('domUtils', () => {
+ describe('pixeliseValue', () => {
+ it('should add px to a given Number', () => {
+ expect(domUtils.pixeliseValue(12)).toEqual('12px');
+ });
+
+ it('should not add px to 0', () => {
+ expect(domUtils.pixeliseValue(0)).toEqual('');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/pagination_links_spec.js b/spec/frontend/vue_shared/components/pagination_links_spec.js
new file mode 100644
index 00000000000..d0cb3731050
--- /dev/null
+++ b/spec/frontend/vue_shared/components/pagination_links_spec.js
@@ -0,0 +1,59 @@
+import Vue from 'vue';
+import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+import { s__ } from '~/locale';
+import mountComponent from '../../helpers/vue_mount_component_helper';
+
+describe('Pagination links component', () => {
+ const paginationLinksComponent = Vue.extend(PaginationLinks);
+ const change = page => page;
+ const pageInfo = {
+ page: 3,
+ perPage: 5,
+ total: 30,
+ };
+ const translations = {
+ firstText: s__('Pagination|« First'),
+ prevText: s__('Pagination|Prev'),
+ nextText: s__('Pagination|Next'),
+ lastText: s__('Pagination|Last »'),
+ };
+
+ let paginationLinks;
+ let glPagination;
+ let destinationComponent;
+
+ beforeEach(() => {
+ paginationLinks = mountComponent(paginationLinksComponent, {
+ change,
+ pageInfo,
+ });
+ [glPagination] = paginationLinks.$children;
+ [destinationComponent] = glPagination.$children;
+ });
+
+ afterEach(() => {
+ paginationLinks.$destroy();
+ });
+
+ it('should provide translated text to GitLab UI pagination', () => {
+ Object.entries(translations).forEach(entry => {
+ expect(destinationComponent[entry[0]]).toBe(entry[1]);
+ });
+ });
+
+ it('should pass change to GitLab UI pagination', () => {
+ expect(Object.is(glPagination.change, change)).toBe(true);
+ });
+
+ it('should pass page from pageInfo to GitLab UI pagination', () => {
+ expect(destinationComponent.value).toBe(pageInfo.page);
+ });
+
+ it('should pass per page from pageInfo to GitLab UI pagination', () => {
+ expect(destinationComponent.perPage).toBe(pageInfo.perPage);
+ });
+
+ it('should pass total items from pageInfo to GitLab UI pagination', () => {
+ expect(destinationComponent.totalRows).toBe(pageInfo.total);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
new file mode 100644
index 00000000000..536bb57b946
--- /dev/null
+++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
+
+describe('Time ago with tooltip component', () => {
+ let TimeagoTooltip;
+ let vm;
+
+ beforeEach(() => {
+ TimeagoTooltip = Vue.extend(timeagoTooltip);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should render timeago with a bootstrap tooltip', () => {
+ vm = new TimeagoTooltip({
+ propsData: {
+ time: '2017-05-08T14:57:39.781Z',
+ },
+ }).$mount();
+
+ expect(vm.$el.tagName).toEqual('TIME');
+ expect(vm.$el.getAttribute('data-original-title')).toEqual(
+ formatDate('2017-05-08T14:57:39.781Z'),
+ );
+
+ const timeago = getTimeago();
+
+ expect(vm.$el.textContent.trim()).toEqual(timeago.format('2017-05-08T14:57:39.781Z'));
+ });
+
+ it('should render provided html class', () => {
+ vm = new TimeagoTooltip({
+ propsData: {
+ time: '2017-05-08T14:57:39.781Z',
+ cssClass: 'foo',
+ },
+ }).$mount();
+
+ expect(vm.$el.classList.contains('foo')).toEqual(true);
+ });
+});
diff --git a/spec/frontend/vuex_shared/modules/modal/mutations_spec.js b/spec/frontend/vuex_shared/modules/modal/mutations_spec.js
new file mode 100644
index 00000000000..d07f8ba1e65
--- /dev/null
+++ b/spec/frontend/vuex_shared/modules/modal/mutations_spec.js
@@ -0,0 +1,49 @@
+import mutations from '~/vuex_shared/modules/modal/mutations';
+import * as types from '~/vuex_shared/modules/modal/mutation_types';
+
+describe('Vuex ModalModule mutations', () => {
+ describe(types.SHOW, () => {
+ it('sets isVisible to true', () => {
+ const state = {
+ isVisible: false,
+ };
+
+ mutations[types.SHOW](state);
+
+ expect(state).toEqual({
+ isVisible: true,
+ });
+ });
+ });
+
+ describe(types.HIDE, () => {
+ it('sets isVisible to false', () => {
+ const state = {
+ isVisible: true,
+ };
+
+ mutations[types.HIDE](state);
+
+ expect(state).toEqual({
+ isVisible: false,
+ });
+ });
+ });
+
+ describe(types.OPEN, () => {
+ it('sets data and sets isVisible to true', () => {
+ const data = { id: 7 };
+ const state = {
+ isVisible: false,
+ data: null,
+ };
+
+ mutations[types.OPEN](state, data);
+
+ expect(state).toEqual({
+ isVisible: true,
+ data,
+ });
+ });
+ });
+});