summaryrefslogtreecommitdiff
path: root/spec/frontend/ide
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
commit9f46488805e86b1bc341ea1620b866016c2ce5ed (patch)
treef9748c7e287041e37d6da49e0a29c9511dc34768 /spec/frontend/ide
parentdfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff)
downloadgitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'spec/frontend/ide')
-rw-r--r--spec/frontend/ide/components/activity_bar_spec.js72
-rw-r--r--spec/frontend/ide/components/commit_sidebar/editor_header_spec.js50
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js111
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_spec.js2
-rw-r--r--spec/frontend/ide/components/commit_sidebar/radio_group_spec.js134
-rw-r--r--spec/frontend/ide/components/file_row_extra_spec.js170
-rw-r--r--spec/frontend/ide/components/file_templates/bar_spec.js117
-rw-r--r--spec/frontend/ide/components/ide_review_spec.js73
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js57
-rw-r--r--spec/frontend/ide/components/ide_spec.js125
-rw-r--r--spec/frontend/ide/components/ide_status_bar_spec.js127
-rw-r--r--spec/frontend/ide/components/ide_tree_list_spec.js77
-rw-r--r--spec/frontend/ide/components/ide_tree_spec.js34
-rw-r--r--spec/frontend/ide/components/jobs/detail/description_spec.js28
-rw-r--r--spec/frontend/ide/components/jobs/item_spec.js39
-rw-r--r--spec/frontend/ide/components/merge_requests/item_spec.js63
-rw-r--r--spec/frontend/ide/components/nav_dropdown_button_spec.js93
-rw-r--r--spec/frontend/ide/components/nav_dropdown_spec.js102
-rw-r--r--spec/frontend/ide/components/new_dropdown/button_spec.js65
-rw-r--r--spec/frontend/ide/components/new_dropdown/index_spec.js84
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js175
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js112
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js17
-rw-r--r--spec/frontend/ide/components/preview/clientside_spec.js8
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js1
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js185
-rw-r--r--spec/frontend/ide/components/repo_tabs_spec.js35
-rw-r--r--spec/frontend/ide/components/shared/tokened_input_spec.js133
-rw-r--r--spec/frontend/ide/lib/common/model_manager_spec.js126
-rw-r--r--spec/frontend/ide/lib/common/model_spec.js137
-rw-r--r--spec/frontend/ide/lib/decorations/controller_spec.js143
-rw-r--r--spec/frontend/ide/lib/diff/controller_spec.js215
-rw-r--r--spec/frontend/ide/lib/editor_spec.js302
-rw-r--r--spec/frontend/ide/lib/languages/vue_spec.js92
-rw-r--r--spec/frontend/ide/services/index_spec.js63
-rw-r--r--spec/frontend/ide/stores/mutations_spec.js41
-rw-r--r--spec/frontend/ide/stores/utils_spec.js71
-rw-r--r--spec/frontend/ide/utils_spec.js92
38 files changed, 3444 insertions, 127 deletions
diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js
new file mode 100644
index 00000000000..8b3853d4535
--- /dev/null
+++ b/spec/frontend/ide/components/activity_bar_spec.js
@@ -0,0 +1,72 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import { leftSidebarViews } from '~/ide/constants';
+import ActivityBar from '~/ide/components/activity_bar.vue';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('IDE activity bar', () => {
+ const Component = Vue.extend(ActivityBar);
+ let vm;
+
+ beforeEach(() => {
+ Vue.set(store.state.projects, 'abcproject', {
+ web_url: 'testing',
+ });
+ Vue.set(store.state, 'currentProjectId', 'abcproject');
+
+ vm = createComponentWithStore(Component, store);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('updateActivityBarView', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'updateActivityBarView').mockImplementation(() => {});
+
+ vm.$mount();
+ });
+
+ it('calls updateActivityBarView with edit value on click', () => {
+ vm.$el.querySelector('.js-ide-edit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.edit.name);
+ });
+
+ it('calls updateActivityBarView with commit value on click', () => {
+ vm.$el.querySelector('.js-ide-commit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.commit.name);
+ });
+
+ it('calls updateActivityBarView with review value on click', () => {
+ vm.$el.querySelector('.js-ide-review-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(leftSidebarViews.review.name);
+ });
+ });
+
+ describe('active item', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ it('sets edit item active', () => {
+ expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active');
+ });
+
+ it('sets commit item active', done => {
+ vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
index a25aba61516..ff780939026 100644
--- a/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/editor_header_spec.js
@@ -7,27 +7,32 @@ import { file } from '../../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
+const TEST_FILE_PATH = 'test/file/path';
+
describe('IDE commit editor header', () => {
let wrapper;
- let f;
let store;
- const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
- const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
-
- beforeEach(() => {
- f = file('file');
- store = createStore();
-
+ const createComponent = (fileProps = {}) => {
wrapper = mount(EditorHeader, {
store,
localVue,
propsData: {
- activeFile: f,
+ activeFile: {
+ ...file(TEST_FILE_PATH),
+ staged: true,
+ ...fileProps,
+ },
},
});
+ };
- jest.spyOn(wrapper.vm, 'discardChanges').mockImplementation();
+ const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
+ const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
+
+ beforeEach(() => {
+ store = createStore();
+ jest.spyOn(store, 'dispatch').mockImplementation();
});
afterEach(() => {
@@ -35,29 +40,38 @@ describe('IDE commit editor header', () => {
wrapper = null;
});
- it('renders button to discard', () => {
- expect(wrapper.vm.$el.querySelectorAll('.btn')).toHaveLength(1);
+ it.each`
+ fileProps | shouldExist
+ ${{ staged: false, changed: false }} | ${false}
+ ${{ staged: true, changed: false }} | ${true}
+ ${{ staged: false, changed: true }} | ${true}
+ ${{ staged: true, changed: true }} | ${true}
+ `('with $fileProps, show discard button is $shouldExist', ({ fileProps, shouldExist }) => {
+ createComponent(fileProps);
+
+ expect(findDiscardButton().exists()).toBe(shouldExist);
});
describe('discard button', () => {
- let modal;
-
beforeEach(() => {
- modal = findDiscardModal();
+ createComponent();
+ const modal = findDiscardModal();
jest.spyOn(modal.vm, 'show');
findDiscardButton().trigger('click');
});
it('opens a dialog confirming discard', () => {
- expect(modal.vm.show).toHaveBeenCalled();
+ expect(findDiscardModal().vm.show).toHaveBeenCalled();
});
it('calls discardFileChanges if dialog result is confirmed', () => {
- modal.vm.$emit('ok');
+ expect(store.dispatch).not.toHaveBeenCalled();
+
+ findDiscardModal().vm.$emit('ok');
- expect(wrapper.vm.discardChanges).toHaveBeenCalledWith(f.path);
+ expect(store.dispatch).toHaveBeenCalledWith('discardFileChanges', TEST_FILE_PATH);
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index dfde69ab2df..129180bb46e 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import waitForPromises from 'helpers/wait_for_promises';
import { projectData } from 'jest/ide/mock_data';
import store from '~/ide/stores';
import CommitForm from '~/ide/components/commit_sidebar/form.vue';
@@ -31,10 +30,10 @@ describe('IDE commit form', () => {
});
describe('compact', () => {
- beforeEach(done => {
+ beforeEach(() => {
vm.isCompact = true;
- vm.$nextTick(done);
+ return vm.$nextTick();
});
it('renders commit button in compact mode', () => {
@@ -46,95 +45,84 @@ describe('IDE commit form', () => {
expect(vm.$el.querySelector('form')).toBeNull();
});
- it('renders overview text', done => {
+ it('renders overview text', () => {
vm.$store.state.stagedFiles.push('test');
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$el.querySelector('p').textContent).toContain('1 changed file');
- done();
});
});
- it('shows form when clicking commit button', done => {
+ it('shows form when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.$el.querySelector('form')).not.toBeNull();
-
- done();
});
});
- it('toggles activity bar view when clicking commit button', done => {
+ it('toggles activity bar view when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name);
-
- done();
});
});
- it('collapses if lastCommitMsg is set to empty and current view is not commit view', done => {
+ it('collapses if lastCommitMsg is set to empty and current view is not commit view', () => {
store.state.lastCommitMsg = 'abc';
store.state.currentActivityView = leftSidebarViews.edit.name;
- vm.$nextTick(() => {
- // if commit message is set, form is uncollapsed
- expect(vm.isCompact).toBe(false);
+ return vm
+ .$nextTick()
+ .then(() => {
+ // if commit message is set, form is uncollapsed
+ expect(vm.isCompact).toBe(false);
- store.state.lastCommitMsg = '';
+ store.state.lastCommitMsg = '';
- vm.$nextTick(() => {
+ return vm.$nextTick();
+ })
+ .then(() => {
// collapsed when set to empty
expect(vm.isCompact).toBe(true);
-
- done();
});
- });
});
});
describe('full', () => {
- beforeEach(done => {
+ beforeEach(() => {
vm.isCompact = false;
- vm.$nextTick(done);
+ return vm.$nextTick();
});
- it('updates commitMessage in store on input', done => {
+ it('updates commitMessage in store on input', () => {
const textarea = vm.$el.querySelector('textarea');
textarea.value = 'testing commit message';
textarea.dispatchEvent(new Event('input'));
- waitForPromises()
- .then(() => {
- expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
- })
- .then(done)
- .catch(done.fail);
+ return vm.$nextTick().then(() => {
+ expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
+ });
});
- it('updating currentActivityView not to commit view sets compact mode', done => {
+ it('updating currentActivityView not to commit view sets compact mode', () => {
store.state.currentActivityView = 'a';
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(vm.isCompact).toBe(true);
-
- done();
});
});
- it('always opens itself in full view current activity view is not commit view when clicking commit button', done => {
+ it('always opens itself in full view current activity view is not commit view when clicking commit button', () => {
vm.$el.querySelector('.btn-primary').click();
- vm.$nextTick(() => {
+ return vm.$nextTick(() => {
expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name);
expect(vm.isCompact).toBe(false);
-
- done();
});
});
@@ -143,41 +131,54 @@ describe('IDE commit form', () => {
expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse');
});
- it('resets commitMessage when clicking discard button', done => {
+ it('resets commitMessage when clicking discard button', () => {
vm.$store.state.commit.commitMessage = 'testing commit message';
- waitForPromises()
+ return vm
+ .$nextTick()
.then(() => {
vm.$el.querySelector('.btn-default').click();
})
- .then(Vue.nextTick)
+ .then(() => vm.$nextTick())
.then(() => {
expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message');
- })
- .then(done)
- .catch(done.fail);
+ });
});
});
describe('when submitting', () => {
beforeEach(() => {
- jest.spyOn(vm, 'commitChanges').mockImplementation(() => {});
+ jest.spyOn(vm, 'commitChanges');
+
vm.$store.state.stagedFiles.push('test');
+ vm.$store.state.commit.commitMessage = 'testing commit message';
});
- it('calls commitChanges', done => {
- vm.$store.state.commit.commitMessage = 'testing commit message';
+ it('calls commitChanges', () => {
+ vm.commitChanges.mockResolvedValue({ success: true });
+
+ return vm.$nextTick().then(() => {
+ vm.$el.querySelector('.btn-success').click();
+
+ expect(vm.commitChanges).toHaveBeenCalled();
+ });
+ });
+
+ it('opens new branch modal if commitChanges throws an error', () => {
+ vm.commitChanges.mockRejectedValue({ success: false });
- waitForPromises()
+ jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation();
+
+ return vm
+ .$nextTick()
.then(() => {
vm.$el.querySelector('.btn-success').click();
+
+ return vm.$nextTick();
})
- .then(Vue.nextTick)
.then(() => {
- expect(vm.commitChanges).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ expect(vm.$refs.createBranchModal.show).toHaveBeenCalled();
+ });
});
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js
index ee209487665..2b5664ffc4e 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js
@@ -21,8 +21,6 @@ describe('Multi-file editor commit sidebar list', () => {
keyPrefix: 'staged',
});
- vm.$store.state.rightPanelCollapsed = false;
-
vm.$mount();
});
diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
new file mode 100644
index 00000000000..ac80ba58056
--- /dev/null
+++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js
@@ -0,0 +1,134 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { resetStore } from 'jest/ide/helpers';
+import store from '~/ide/stores';
+import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue';
+
+describe('IDE commit sidebar radio group', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '2';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('uses label if present', () => {
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('uses slot if label is not present', done => {
+ vm.$destroy();
+
+ vm = new Vue({
+ components: {
+ radioGroup,
+ },
+ store,
+ render: createElement =>
+ createElement('radio-group', { props: { value: '1' } }, 'Testing slot'),
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(() => {
+ expect(vm.$el.textContent).toContain('Testing slot');
+
+ done();
+ });
+ });
+
+ it('updates store when changing radio button', done => {
+ vm.$el.querySelector('input').dispatchEvent(new Event('change'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.commitAction).toBe('1');
+
+ done();
+ });
+ });
+
+ describe('with input', () => {
+ beforeEach(done => {
+ vm.$destroy();
+
+ const Component = Vue.extend(radioGroup);
+
+ store.state.commit.commitAction = '1';
+ store.state.commit.newBranchName = 'test-123';
+
+ vm = createComponentWithStore(Component, store, {
+ value: '1',
+ label: 'test',
+ checked: true,
+ showInput: true,
+ });
+
+ vm.$mount();
+
+ Vue.nextTick(done);
+ });
+
+ it('renders input box when commitAction matches value', () => {
+ expect(vm.$el.querySelector('.form-control')).not.toBeNull();
+ });
+
+ it('hides input when commitAction doesnt match value', done => {
+ store.state.commit.commitAction = '2';
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.form-control')).toBeNull();
+ done();
+ });
+ });
+
+ it('updates branch name in store on input', done => {
+ const input = vm.$el.querySelector('.form-control');
+ input.value = 'testing-123';
+ input.dispatchEvent(new Event('input'));
+
+ Vue.nextTick(() => {
+ expect(store.state.commit.newBranchName).toBe('testing-123');
+
+ done();
+ });
+ });
+
+ it('renders newBranchName if present', () => {
+ const input = vm.$el.querySelector('.form-control');
+
+ expect(input.value).toBe('test-123');
+ });
+ });
+
+ describe('tooltipTitle', () => {
+ it('returns title when disabled', () => {
+ vm.title = 'test title';
+ vm.disabled = true;
+
+ expect(vm.tooltipTitle).toBe('test title');
+ });
+
+ it('returns blank when not disabled', () => {
+ vm.title = 'test title';
+
+ expect(vm.tooltipTitle).not.toBe('test title');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js
new file mode 100644
index 00000000000..e78bacadebb
--- /dev/null
+++ b/spec/frontend/ide/components/file_row_extra_spec.js
@@ -0,0 +1,170 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import FileRowExtra from '~/ide/components/file_row_extra.vue';
+import { file, resetStore } from '../helpers';
+
+describe('IDE extra file row component', () => {
+ let Component;
+ let vm;
+ let unstagedFilesCount = 0;
+ let stagedFilesCount = 0;
+ let changesCount = 0;
+
+ beforeAll(() => {
+ Component = Vue.extend(FileRowExtra);
+ });
+
+ beforeEach(() => {
+ vm = createComponentWithStore(Component, createStore(), {
+ file: {
+ ...file('test'),
+ },
+ dropdownOpen: false,
+ });
+
+ jest.spyOn(vm, 'getUnstagedFilesCountForPath', 'get').mockReturnValue(() => unstagedFilesCount);
+ jest.spyOn(vm, 'getStagedFilesCountForPath', 'get').mockReturnValue(() => stagedFilesCount);
+ jest.spyOn(vm, 'getChangesInFolder', 'get').mockReturnValue(() => changesCount);
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+
+ stagedFilesCount = 0;
+ unstagedFilesCount = 0;
+ changesCount = 0;
+ });
+
+ describe('folderChangesTooltip', () => {
+ it('returns undefined when changes count is 0', () => {
+ changesCount = 0;
+
+ expect(vm.folderChangesTooltip).toBe(undefined);
+ });
+
+ [{ input: 1, output: '1 changed file' }, { input: 2, output: '2 changed files' }].forEach(
+ ({ input, output }) => {
+ it('returns changed files count if changes count is not 0', () => {
+ changesCount = input;
+
+ expect(vm.folderChangesTooltip).toBe(output);
+ });
+ },
+ );
+ });
+
+ describe('show tree changes count', () => {
+ it('does not show for blobs', () => {
+ vm.file.type = 'blob';
+
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+ });
+
+ it('does not show when changes count is 0', () => {
+ vm.file.type = 'tree';
+
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+ });
+
+ it('does not show when tree is open', done => {
+ vm.file.type = 'tree';
+ vm.file.opened = true;
+ changesCount = 1;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows for trees with changes', done => {
+ vm.file.type = 'tree';
+ vm.file.opened = false;
+ changesCount = 1;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ describe('changes file icon', () => {
+ it('hides when file is not changed', () => {
+ expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
+ });
+
+ it('shows when file is changed', done => {
+ vm.file.changed = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is staged', done => {
+ vm.file.staged = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is a tempFile', done => {
+ vm.file.tempFile = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('shows when file is renamed', done => {
+ vm.file.prevPath = 'original-file';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ it('hides when file is renamed', done => {
+ vm.file.prevPath = 'original-file';
+ vm.file.type = 'tree';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.file-changed-icon')).toBe(null);
+
+ done();
+ });
+ });
+ });
+
+ describe('merge request icon', () => {
+ it('hides when not a merge request change', () => {
+ expect(vm.$el.querySelector('.ic-git-merge')).toBe(null);
+ });
+
+ it('shows when a merge request change', done => {
+ vm.file.mrChange = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null);
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js
new file mode 100644
index 00000000000..21dbe18a223
--- /dev/null
+++ b/spec/frontend/ide/components/file_templates/bar_spec.js
@@ -0,0 +1,117 @@
+import Vue from 'vue';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import Bar from '~/ide/components/file_templates/bar.vue';
+import { resetStore, file } from '../../helpers';
+
+describe('IDE file templates bar component', () => {
+ let Component;
+ let vm;
+
+ beforeAll(() => {
+ Component = Vue.extend(Bar);
+ });
+
+ beforeEach(() => {
+ const store = createStore();
+
+ store.state.openFiles.push({
+ ...file('file'),
+ opened: true,
+ active: true,
+ });
+
+ vm = mountComponentWithStore(Component, { store });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ resetStore(vm.$store);
+ });
+
+ describe('template type dropdown', () => {
+ it('renders dropdown component', () => {
+ expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type');
+ });
+
+ it('calls setSelectedTemplateType when clicking item', () => {
+ jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation();
+
+ vm.$el.querySelector('.dropdown-content button').click();
+
+ expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
+ name: '.gitlab-ci.yml',
+ key: 'gitlab_ci_ymls',
+ });
+ });
+ });
+
+ describe('template dropdown', () => {
+ beforeEach(done => {
+ vm.$store.state.fileTemplates.templates = [
+ {
+ name: 'test',
+ },
+ ];
+ vm.$store.state.fileTemplates.selectedTemplateType = {
+ name: '.gitlab-ci.yml',
+ key: 'gitlab_ci_ymls',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('renders dropdown component', () => {
+ expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template');
+ });
+
+ it('calls fetchTemplate on click', () => {
+ jest.spyOn(vm, 'fetchTemplate').mockImplementation();
+
+ vm.$el
+ .querySelectorAll('.dropdown-content')[1]
+ .querySelector('button')
+ .click();
+
+ expect(vm.fetchTemplate).toHaveBeenCalledWith({
+ name: 'test',
+ });
+ });
+ });
+
+ it('shows undo button if updateSuccess is true', done => {
+ vm.$store.state.fileTemplates.updateSuccess = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none');
+
+ done();
+ });
+ });
+
+ it('calls undoFileTemplate when clicking undo button', () => {
+ jest.spyOn(vm, 'undoFileTemplate').mockImplementation();
+
+ vm.$el.querySelector('.btn-default').click();
+
+ expect(vm.undoFileTemplate).toHaveBeenCalled();
+ });
+
+ it('calls setSelectedTemplateType if activeFile name matches a template', done => {
+ const fileName = '.gitlab-ci.yml';
+
+ jest.spyOn(vm, 'setSelectedTemplateType').mockImplementation(() => {});
+ vm.$store.state.openFiles[0].name = fileName;
+
+ vm.setInitialType();
+
+ vm.$nextTick(() => {
+ expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({
+ name: fileName,
+ key: 'gitlab_ci_ymls',
+ });
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js
new file mode 100644
index 00000000000..b56957e1f6d
--- /dev/null
+++ b/spec/frontend/ide/components/ide_review_spec.js
@@ -0,0 +1,73 @@
+import Vue from 'vue';
+import IdeReview from '~/ide/components/ide_review.vue';
+import { createStore } from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/text_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE review mode', () => {
+ const Component = Vue.extend(IdeReview);
+ let vm;
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+
+ describe('merge request', () => {
+ beforeEach(() => {
+ store.state.currentMergeRequestId = '1';
+ store.state.projects.abcproject.mergeRequests['1'] = {
+ iid: 123,
+ web_url: 'testing123',
+ };
+
+ return vm.$nextTick();
+ });
+
+ it('renders edit dropdown', () => {
+ expect(vm.$el.querySelector('.btn')).not.toBe(null);
+ });
+
+ it('renders merge request link & IID', () => {
+ store.state.viewer = 'mrdiff';
+
+ return vm.$nextTick(() => {
+ const link = vm.$el.querySelector('.ide-review-sub-header');
+
+ expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
+ expect(trimText(link.textContent)).toBe('Merge request (!123)');
+ });
+ });
+
+ it('changes text to latest changes when viewer is not mrdiff', () => {
+ store.state.viewer = 'diff';
+
+ return vm.$nextTick(() => {
+ expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
+ 'Latest changes',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
new file mode 100644
index 00000000000..65cad2e7eb0
--- /dev/null
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -0,0 +1,57 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import { leftSidebarViews } from '~/ide/constants';
+import { resetStore } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeSidebar', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(ideSidebar);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.projects.abcproject = projectData;
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a sidebar', () => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
+ });
+
+ it('renders loading icon component', done => {
+ vm.$store.state.loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ describe('activityBarComponent', () => {
+ it('renders tree component', () => {
+ expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
+ });
+
+ it('renders commit component', done => {
+ vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
new file mode 100644
index 00000000000..78a280e6304
--- /dev/null
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import ide from '~/ide/components/ide.vue';
+import { file, resetStore } from '../helpers';
+import { projectData } from '../mock_data';
+
+function bootstrap(projData) {
+ const Component = Vue.extend(ide);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [],
+ loading: false,
+ });
+
+ return createComponentWithStore(Component, store, {
+ emptyStateSvgPath: 'svg',
+ noChangesStateSvgPath: 'svg',
+ committedStateSvgPath: 'svg',
+ });
+}
+
+describe('ide component, empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ const emptyProjData = { ...projectData, empty_repo: true, branches: {} };
+ vm = bootstrap(emptyProjData);
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders "New file" button in empty repo', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).not.toBeNull();
+ done();
+ });
+ });
+});
+
+describe('ide component, non-empty repo', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = bootstrap(projectData);
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('shows error message when set', done => {
+ expect(vm.$el.querySelector('.gl-alert')).toBe(null);
+
+ vm.$store.state.errorMessage = {
+ text: 'error',
+ };
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
+
+ done();
+ });
+ });
+
+ describe('onBeforeUnload', () => {
+ it('returns undefined when no staged files or changed files', () => {
+ expect(vm.onBeforeUnload()).toBe(undefined);
+ });
+
+ it('returns warning text when their are changed files', () => {
+ vm.$store.state.changedFiles.push(file());
+
+ expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
+
+ it('returns warning text when their are staged files', () => {
+ vm.$store.state.stagedFiles.push(file());
+
+ expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
+
+ it('updates event object', () => {
+ const event = {};
+ vm.$store.state.stagedFiles.push(file());
+
+ vm.onBeforeUnload(event);
+
+ expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?');
+ });
+ });
+
+ describe('non-existent branch', () => {
+ it('does not render "New file" button for non-existent branch when repo is not empty', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
+
+ describe('branch with files', () => {
+ beforeEach(() => {
+ store.state.trees['abcproject/master'].tree = [file()];
+ });
+
+ it('does not render "New file" button', done => {
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_status_bar_spec.js b/spec/frontend/ide/components/ide_status_bar_spec.js
new file mode 100644
index 00000000000..bc8144f544c
--- /dev/null
+++ b/spec/frontend/ide/components/ide_status_bar_spec.js
@@ -0,0 +1,127 @@
+import Vue from 'vue';
+import _ from 'lodash';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { TEST_HOST } from '../../helpers/test_constants';
+import { createStore } from '~/ide/stores';
+import IdeStatusBar from '~/ide/components/ide_status_bar.vue';
+import { rightSidebarViews } from '~/ide/constants';
+import { projectData } from '../mock_data';
+
+const TEST_PROJECT_ID = 'abcproject';
+const TEST_MERGE_REQUEST_ID = '9001';
+const TEST_MERGE_REQUEST_URL = `${TEST_HOST}merge-requests/${TEST_MERGE_REQUEST_ID}`;
+
+describe('ideStatusBar', () => {
+ let store;
+ let vm;
+
+ const createComponent = () => {
+ vm = createComponentWithStore(Vue.extend(IdeStatusBar), store).$mount();
+ };
+ const findMRStatus = () => vm.$el.querySelector('.js-ide-status-mr');
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.currentProjectId = TEST_PROJECT_ID;
+ store.state.projects[TEST_PROJECT_ID] = _.clone(projectData);
+ store.state.currentBranchId = 'master';
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('triggers a setInterval', () => {
+ expect(vm.intervalId).not.toBe(null);
+ });
+
+ it('renders the statusbar', () => {
+ expect(vm.$el.className).toBe('ide-status-bar');
+ });
+
+ describe('commitAgeUpdate', () => {
+ beforeEach(() => {
+ jest.spyOn(vm, 'commitAgeUpdate').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ it('gets called every second', () => {
+ expect(vm.commitAgeUpdate).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(1000);
+
+ expect(vm.commitAgeUpdate.mock.calls.length).toEqual(1);
+
+ jest.advanceTimersByTime(1000);
+
+ expect(vm.commitAgeUpdate.mock.calls.length).toEqual(2);
+ });
+ });
+
+ describe('getCommitPath', () => {
+ it('returns the path to the commit details', () => {
+ expect(vm.getCommitPath('abc123de')).toBe('/commit/abc123de');
+ });
+ });
+
+ describe('pipeline status', () => {
+ it('opens right sidebar on clicking icon', done => {
+ jest.spyOn(vm, 'openRightPane').mockImplementation(() => {});
+ Vue.set(vm.$store.state.pipelines, 'latestPipeline', {
+ details: {
+ status: {
+ text: 'success',
+ details_path: 'test',
+ icon: 'status_success',
+ },
+ },
+ commit: {
+ author_gravatar_url: 'www',
+ },
+ });
+
+ vm.$nextTick()
+ .then(() => {
+ vm.$el.querySelector('.ide-status-pipeline button').click();
+
+ expect(vm.openRightPane).toHaveBeenCalledWith(rightSidebarViews.pipelines);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ it('does not show merge request status', () => {
+ expect(findMRStatus()).toBe(null);
+ });
+ });
+
+ describe('with merge request in store', () => {
+ beforeEach(() => {
+ store.state.projects[TEST_PROJECT_ID].mergeRequests = {
+ [TEST_MERGE_REQUEST_ID]: {
+ web_url: TEST_MERGE_REQUEST_URL,
+ references: {
+ short: `!${TEST_MERGE_REQUEST_ID}`,
+ },
+ },
+ };
+ store.state.currentMergeRequestId = TEST_MERGE_REQUEST_ID;
+
+ createComponent();
+ });
+
+ it('shows merge request status', () => {
+ expect(findMRStatus().textContent.trim()).toEqual(`Merge request !${TEST_MERGE_REQUEST_ID}`);
+ expect(findMRStatus().querySelector('a').href).toEqual(TEST_MERGE_REQUEST_URL);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js
new file mode 100644
index 00000000000..30f11db3153
--- /dev/null
+++ b/spec/frontend/ide/components/ide_tree_list_spec.js
@@ -0,0 +1,77 @@
+import Vue from 'vue';
+import IdeTreeList from '~/ide/components/ide_tree_list.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE tree list', () => {
+ const Component = Vue.extend(IdeTreeList);
+ const normalBranchTree = [file('fileName')];
+ const emptyBranchTree = [];
+ let vm;
+
+ const bootstrapWithTree = (tree = normalBranchTree) => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree,
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store, {
+ viewerType: 'edit',
+ });
+ };
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('normal branch', () => {
+ beforeEach(() => {
+ bootstrapWithTree();
+
+ jest.spyOn(vm, 'updateViewer');
+
+ vm.$mount();
+ });
+
+ it('updates viewer on mount', () => {
+ expect(vm.updateViewer).toHaveBeenCalledWith('edit');
+ });
+
+ it('renders loading indicator', done => {
+ store.state.trees['abcproject/master'].loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+ });
+
+ describe('empty-branch state', () => {
+ beforeEach(() => {
+ bootstrapWithTree(emptyBranchTree);
+
+ jest.spyOn(vm, 'updateViewer');
+
+ vm.$mount();
+ });
+
+ it('does not load files if the branch is empty', () => {
+ expect(vm.$el.textContent).not.toContain('fileName');
+ expect(vm.$el.textContent).toContain('No files');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js
new file mode 100644
index 00000000000..01f007f09c3
--- /dev/null
+++ b/spec/frontend/ide/components/ide_tree_spec.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import IdeTree from '~/ide/components/ide_tree.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeRepoTree', () => {
+ let vm;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(IdeTree);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projectData };
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(IdeRepoTree, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+});
diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js
new file mode 100644
index 00000000000..babae00d2f7
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/detail/description_spec.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import Description from '~/ide/components/jobs/detail/description.vue';
+import mountComponent from '../../../../helpers/vue_mount_component_helper';
+import { jobs } from '../../../mock_data';
+
+describe('IDE job description', () => {
+ const Component = Vue.extend(Description);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job: jobs[0],
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders job details', () => {
+ expect(vm.$el.textContent).toContain('#1');
+ expect(vm.$el.textContent).toContain('test');
+ });
+
+ it('renders CI icon', () => {
+ expect(vm.$el.querySelector('.ci-status-icon .ic-status_success_borderless')).not.toBe(null);
+ });
+});
diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js
new file mode 100644
index 00000000000..2f97d39e98e
--- /dev/null
+++ b/spec/frontend/ide/components/jobs/item_spec.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import JobItem from '~/ide/components/jobs/item.vue';
+import mountComponent from '../../../helpers/vue_mount_component_helper';
+import { jobs } from '../../mock_data';
+
+describe('IDE jobs item', () => {
+ const Component = Vue.extend(JobItem);
+ const job = jobs[0];
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ job,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders job details', () => {
+ expect(vm.$el.textContent).toContain(job.name);
+ expect(vm.$el.textContent).toContain(`#${job.id}`);
+ });
+
+ it('renders CI icon', () => {
+ expect(vm.$el.querySelector('.ic-status_success_borderless')).not.toBe(null);
+ });
+
+ it('does not render view logs button if not started', done => {
+ vm.job.started = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.btn')).toBe(null);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js
new file mode 100644
index 00000000000..6a2451ad263
--- /dev/null
+++ b/spec/frontend/ide/components/merge_requests/item_spec.js
@@ -0,0 +1,63 @@
+import Vue from 'vue';
+import router from '~/ide/ide_router';
+import Item from '~/ide/components/merge_requests/item.vue';
+import mountCompontent from '../../../helpers/vue_mount_component_helper';
+
+describe('IDE merge request item', () => {
+ const Component = Vue.extend(Item);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountCompontent(Component, {
+ item: {
+ iid: 1,
+ projectPathWithNamespace: 'gitlab-org/gitlab-ce',
+ title: 'Merge request title',
+ },
+ currentId: '1',
+ currentProjectId: 'gitlab-org/gitlab-ce',
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders merge requests data', () => {
+ expect(vm.$el.textContent).toContain('Merge request title');
+ expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1');
+ });
+
+ it('renders link with href', () => {
+ const expectedHref = router.resolve(
+ `/project/${vm.item.projectPathWithNamespace}/merge_requests/${vm.item.iid}`,
+ ).href;
+
+ expect(vm.$el.tagName.toLowerCase()).toBe('a');
+ expect(vm.$el).toHaveAttr('href', expectedHref);
+ });
+
+ it('renders icon if ID matches currentId', () => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null);
+ });
+
+ it('does not render icon if ID does not match currentId', done => {
+ vm.currentId = '2';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('does not render icon if project ID does not match', done => {
+ vm.currentProjectId = 'test/test';
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.ic-mobile-issue-close')).toBe(null);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js
new file mode 100644
index 00000000000..2aa3992a6d8
--- /dev/null
+++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js
@@ -0,0 +1,93 @@
+import Vue from 'vue';
+import { trimText } from 'helpers/text_helper';
+import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
+import NavDropdownButton from '~/ide/components/nav_dropdown_button.vue';
+import { createStore } from '~/ide/stores';
+
+describe('NavDropdown', () => {
+ const TEST_BRANCH_ID = 'lorem-ipsum-dolar';
+ const TEST_MR_ID = '12345';
+ let store;
+ let vm;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ const createComponent = (props = {}) => {
+ vm = mountComponentWithStore(Vue.extend(NavDropdownButton), { props, store });
+ vm.$mount();
+ };
+
+ const findIcon = name => vm.$el.querySelector(`.ic-${name}`);
+ const findMRIcon = () => findIcon('merge-request');
+ const findBranchIcon = () => findIcon('branch');
+
+ describe('normal', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty placeholders, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('- -');
+ });
+
+ it('renders branch name, if state has currentBranchId', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} -`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders mr id, if state has currentMergeRequestId', done => {
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`- !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders branch and mr, if state has both', done => {
+ vm.$store.state.currentBranchId = TEST_BRANCH_ID;
+ vm.$store.state.currentMergeRequestId = TEST_MR_ID;
+
+ vm.$nextTick()
+ .then(() => {
+ expect(trimText(vm.$el.textContent)).toEqual(`${TEST_BRANCH_ID} !${TEST_MR_ID}`);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('shows icons', () => {
+ expect(findBranchIcon()).toBeTruthy();
+ expect(findMRIcon()).toBeTruthy();
+ });
+ });
+
+ describe('with showMergeRequests false', () => {
+ beforeEach(() => {
+ createComponent({ showMergeRequests: false });
+ });
+
+ it('shows single empty placeholder, if state is falsey', () => {
+ expect(trimText(vm.$el.textContent)).toEqual('-');
+ });
+
+ it('shows only branch icon', () => {
+ expect(findBranchIcon()).toBeTruthy();
+ expect(findMRIcon()).toBe(null);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js
new file mode 100644
index 00000000000..ce123d925c8
--- /dev/null
+++ b/spec/frontend/ide/components/nav_dropdown_spec.js
@@ -0,0 +1,102 @@
+import $ from 'jquery';
+import { mount } from '@vue/test-utils';
+import { createStore } from '~/ide/stores';
+import NavDropdown from '~/ide/components/nav_dropdown.vue';
+import { PERMISSION_READ_MR } from '~/ide/constants';
+
+const TEST_PROJECT_ID = 'lorem-ipsum';
+
+describe('IDE NavDropdown', () => {
+ let store;
+ let wrapper;
+
+ beforeEach(() => {
+ store = createStore();
+ Object.assign(store.state, {
+ currentProjectId: TEST_PROJECT_ID,
+ currentBranchId: 'master',
+ projects: {
+ [TEST_PROJECT_ID]: {
+ userPermissions: {
+ [PERMISSION_READ_MR]: true,
+ },
+ branches: {
+ master: { id: 'master' },
+ },
+ },
+ },
+ });
+ jest.spyOn(store, 'dispatch').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const createComponent = () => {
+ wrapper = mount(NavDropdown, {
+ store,
+ });
+ };
+
+ const findIcon = name => wrapper.find(`.ic-${name}`);
+ const findMRIcon = () => findIcon('merge-request');
+ const findNavForm = () => wrapper.find('.ide-nav-form');
+ const showDropdown = () => {
+ $(wrapper.vm.$el).trigger('show.bs.dropdown');
+ };
+ const hideDropdown = () => {
+ $(wrapper.vm.$el).trigger('hide.bs.dropdown');
+ };
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders nothing initially', () => {
+ expect(findNavForm().exists()).toBe(false);
+ });
+
+ it('renders nav form when show.bs.dropdown', done => {
+ showDropdown();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(findNavForm().exists()).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('destroys nav form when closed', done => {
+ showDropdown();
+ hideDropdown();
+
+ wrapper.vm
+ .$nextTick()
+ .then(() => {
+ expect(findNavForm().exists()).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('renders merge request icon', () => {
+ expect(findMRIcon().exists()).toBe(true);
+ });
+ });
+
+ describe('when user cannot read merge requests', () => {
+ beforeEach(() => {
+ store.state.projects[TEST_PROJECT_ID].userPermissions = {};
+
+ createComponent();
+ });
+
+ it('does not render merge requests', () => {
+ expect(findMRIcon().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js
new file mode 100644
index 00000000000..3c611b7de8f
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/button_spec.js
@@ -0,0 +1,65 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import Button from '~/ide/components/new_dropdown/button.vue';
+
+describe('IDE new entry dropdown button component', () => {
+ let Component;
+ let vm;
+
+ beforeAll(() => {
+ Component = Vue.extend(Button);
+ });
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ label: 'Testing',
+ icon: 'doc-new',
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders button with label', () => {
+ expect(vm.$el.textContent).toContain('Testing');
+ });
+
+ it('renders icon', () => {
+ expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null);
+ });
+
+ it('emits click event', () => {
+ vm.$el.click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('click');
+ });
+
+ it('hides label if showLabel is false', done => {
+ vm.showLabel = false;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.textContent).not.toContain('Testing');
+
+ done();
+ });
+ });
+
+ describe('tooltipTitle', () => {
+ it('returns empty string when showLabel is true', () => {
+ expect(vm.tooltipTitle).toBe('');
+ });
+
+ it('returns label', done => {
+ vm.showLabel = false;
+
+ vm.$nextTick(() => {
+ expect(vm.tooltipTitle).toBe('Testing');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js
new file mode 100644
index 00000000000..00781c16609
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/index_spec.js
@@ -0,0 +1,84 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import store from '~/ide/stores';
+import newDropdown from '~/ide/components/new_dropdown/index.vue';
+import { resetStore } from '../../helpers';
+
+describe('new dropdown component', () => {
+ let vm;
+
+ beforeEach(() => {
+ const component = Vue.extend(newDropdown);
+
+ vm = createComponentWithStore(component, store, {
+ branch: 'master',
+ path: '',
+ mouseOver: false,
+ type: 'tree',
+ });
+
+ vm.$store.state.currentProjectId = 'abcproject';
+ vm.$store.state.path = '';
+ vm.$store.state.trees['abcproject/mybranch'] = {
+ tree: [],
+ };
+
+ vm.$mount();
+
+ jest.spyOn(vm.$refs.newModal, 'open').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders new file, upload and new directory links', () => {
+ const buttons = vm.$el.querySelectorAll('.dropdown-menu button');
+
+ expect(buttons[0].textContent.trim()).toBe('New file');
+ expect(buttons[1].textContent.trim()).toBe('Upload file');
+ expect(buttons[2].textContent.trim()).toBe('New directory');
+ });
+
+ describe('createNewItem', () => {
+ it('opens modal for a blob when new file is clicked', () => {
+ vm.$el.querySelectorAll('.dropdown-menu button')[0].click();
+
+ expect(vm.$refs.newModal.open).toHaveBeenCalledWith('blob', '');
+ });
+
+ it('opens modal for a tree when new directory is clicked', () => {
+ vm.$el.querySelectorAll('.dropdown-menu button')[2].click();
+
+ expect(vm.$refs.newModal.open).toHaveBeenCalledWith('tree', '');
+ });
+ });
+
+ describe('isOpen', () => {
+ it('scrolls dropdown into view', done => {
+ jest.spyOn(vm.$refs.dropdownMenu, 'scrollIntoView').mockImplementation(() => {});
+
+ vm.isOpen = true;
+
+ setImmediate(() => {
+ expect(vm.$refs.dropdownMenu.scrollIntoView).toHaveBeenCalledWith({
+ block: 'nearest',
+ });
+
+ done();
+ });
+ });
+ });
+
+ describe('delete entry', () => {
+ it('calls delete action', () => {
+ jest.spyOn(vm, 'deleteEntry').mockImplementation(() => {});
+
+ vm.$el.querySelectorAll('.dropdown-menu button')[4].click();
+
+ expect(vm.deleteEntry).toHaveBeenCalledWith('');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
new file mode 100644
index 00000000000..62a59a76bf4
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -0,0 +1,175 @@
+import Vue from 'vue';
+import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { createStore } from '~/ide/stores';
+import modal from '~/ide/components/new_dropdown/modal.vue';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
+
+describe('new file modal component', () => {
+ const Component = Vue.extend(modal);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe.each`
+ entryType | modalTitle | btnTitle | showsFileTemplates
+ ${'tree'} | ${'Create new directory'} | ${'Create directory'} | ${false}
+ ${'blob'} | ${'Create new file'} | ${'Create file'} | ${true}
+ `('$entryType', ({ entryType, modalTitle, btnTitle, showsFileTemplates }) => {
+ beforeEach(done => {
+ const store = createStore();
+
+ vm = createComponentWithStore(Component, store).$mount();
+ vm.open(entryType);
+ vm.name = 'testing';
+
+ vm.$nextTick(done);
+ });
+
+ afterEach(() => {
+ vm.close();
+ });
+
+ it(`sets modal title as ${entryType}`, () => {
+ expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ });
+
+ it(`sets button label as ${entryType}`, () => {
+ expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+ });
+
+ it(`sets form label as ${entryType}`, () => {
+ expect(document.querySelector('.label-bold').textContent.trim()).toBe('Name');
+ });
+
+ it(`shows file templates: ${showsFileTemplates}`, () => {
+ const templateFilesEl = document.querySelector('.file-templates');
+ expect(Boolean(templateFilesEl)).toBe(showsFileTemplates);
+ });
+ });
+
+ describe('rename entry', () => {
+ beforeEach(() => {
+ const store = createStore();
+ store.state.entries = {
+ 'test-path': {
+ name: 'test',
+ type: 'blob',
+ path: 'test-path',
+ },
+ };
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ it.each`
+ entryType | modalTitle | btnTitle
+ ${'tree'} | ${'Rename folder'} | ${'Rename folder'}
+ ${'blob'} | ${'Rename file'} | ${'Rename file'}
+ `(
+ 'renders title and button for renaming $entryType',
+ ({ entryType, modalTitle, btnTitle }, done) => {
+ vm.$store.state.entries['test-path'].type = entryType;
+ vm.open('rename', 'test-path');
+
+ vm.$nextTick(() => {
+ expect(document.querySelector('.modal-title').textContent.trim()).toBe(modalTitle);
+ expect(document.querySelector('.btn-success').textContent.trim()).toBe(btnTitle);
+
+ done();
+ });
+ },
+ );
+
+ describe('entryName', () => {
+ it('returns entries name', () => {
+ vm.open('rename', 'test-path');
+
+ expect(vm.entryName).toBe('test-path');
+ });
+
+ it('does not reset entryName to its old value if empty', () => {
+ vm.entryName = 'hello';
+ vm.entryName = '';
+
+ expect(vm.entryName).toBe('');
+ });
+ });
+
+ describe('open', () => {
+ it('sets entryName to path provided if modalType is rename', () => {
+ vm.open('rename', 'test-path');
+
+ expect(vm.entryName).toBe('test-path');
+ });
+
+ it("appends '/' to the path if modalType isn't rename", () => {
+ vm.open('blob', 'test-path');
+
+ expect(vm.entryName).toBe('test-path/');
+ });
+
+ it('leaves entryName blank if no path is provided', () => {
+ vm.open('blob');
+
+ expect(vm.entryName).toBe('');
+ });
+ });
+ });
+
+ describe('submitForm', () => {
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ store.state.entries = {
+ 'test-path/test': {
+ name: 'test',
+ deleted: false,
+ },
+ };
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ it('throws an error when target entry exists', () => {
+ vm.open('rename', 'test-path/test');
+
+ expect(createFlash).not.toHaveBeenCalled();
+
+ vm.submitForm();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ 'The name "test-path/test" is already taken in this directory.',
+ 'alert',
+ expect.anything(),
+ null,
+ false,
+ true,
+ );
+ });
+
+ it('does not throw error when target entry does not exist', () => {
+ jest.spyOn(vm, 'renameEntry').mockImplementation();
+
+ vm.open('rename', 'test-path/test');
+ vm.entryName = 'test-path/test2';
+ vm.submitForm();
+
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+
+ it('removes leading/trailing found in the new name', () => {
+ vm.open('rename', 'test-path/test');
+
+ vm.entryName = 'test-path /test';
+
+ vm.submitForm();
+
+ expect(vm.entryName).toBe('test-path/test');
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
new file mode 100644
index 00000000000..a418fdeb572
--- /dev/null
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -0,0 +1,112 @@
+import Vue from 'vue';
+import createComponent from 'helpers/vue_mount_component_helper';
+import upload from '~/ide/components/new_dropdown/upload.vue';
+
+describe('new dropdown upload', () => {
+ let vm;
+
+ beforeEach(() => {
+ const Component = Vue.extend(upload);
+
+ vm = createComponent(Component, {
+ path: '',
+ });
+
+ vm.entryName = 'testing';
+
+ jest.spyOn(vm, '$emit');
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('openFile', () => {
+ it('calls for each file', () => {
+ const files = ['test', 'test2', 'test3'];
+
+ jest.spyOn(vm, 'readFile').mockImplementation(() => {});
+ jest.spyOn(vm.$refs.fileUpload, 'files', 'get').mockReturnValue(files);
+
+ vm.openFile();
+
+ expect(vm.readFile.mock.calls.length).toBe(3);
+
+ files.forEach((file, i) => {
+ expect(vm.readFile.mock.calls[i]).toEqual([file]);
+ });
+ });
+ });
+
+ describe('readFile', () => {
+ beforeEach(() => {
+ jest.spyOn(FileReader.prototype, 'readAsDataURL').mockImplementation(() => {});
+ });
+
+ it('calls readAsDataURL for all files', () => {
+ const file = {
+ type: 'images/png',
+ };
+
+ vm.readFile(file);
+
+ expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file);
+ });
+ });
+
+ describe('createFile', () => {
+ const textTarget = {
+ result: 'base64,cGxhaW4gdGV4dA==',
+ };
+ const binaryTarget = {
+ result: 'base64,w4I=',
+ };
+ const textFile = new File(['plain text'], 'textFile');
+
+ const binaryFile = {
+ name: 'binaryFile',
+ type: 'image/png',
+ };
+
+ beforeEach(() => {
+ jest.spyOn(FileReader.prototype, 'readAsText');
+ });
+
+ it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => {
+ const waitForCreate = new Promise(resolve => vm.$on('create', resolve));
+
+ vm.createFile(textTarget, textFile);
+
+ expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile);
+
+ waitForCreate
+ .then(() => {
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: textFile.name,
+ type: 'blob',
+ content: 'plain text',
+ base64: false,
+ binary: false,
+ rawPath: '',
+ });
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('splits content on base64 if binary', () => {
+ vm.createFile(binaryTarget, binaryFile);
+
+ expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile);
+
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: binaryFile.name,
+ type: 'blob',
+ content: binaryTarget.result.split('base64,')[1],
+ base64: true,
+ binary: true,
+ rawPath: binaryTarget.result,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index 11e672b6685..d909a5e478e 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -7,10 +7,15 @@ import JobsList from '~/ide/components/jobs/list.vue';
import Tab from '~/vue_shared/components/tabs/tab.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { pipelines } from '../../../../javascripts/ide/mock_data';
+import IDEServices from '~/ide/services';
const localVue = createLocalVue();
localVue.use(Vuex);
+jest.mock('~/ide/services', () => ({
+ pingUsage: jest.fn(),
+}));
+
describe('IDE pipelines list', () => {
let wrapper;
@@ -25,14 +30,18 @@ describe('IDE pipelines list', () => {
};
const fetchLatestPipelineMock = jest.fn();
+ const pingUsageMock = jest.fn();
const failedStagesGetterMock = jest.fn().mockReturnValue([]);
+ const fakeProjectPath = 'alpha/beta';
const createComponent = (state = {}) => {
const { pipelines: pipelinesState, ...restOfState } = state;
const { defaultPipelines, ...defaultRestOfState } = defaultState;
const fakeStore = new Vuex.Store({
- getters: { currentProject: () => ({ web_url: 'some/url ' }) },
+ getters: {
+ currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }),
+ },
state: {
...defaultRestOfState,
...restOfState,
@@ -46,6 +55,7 @@ describe('IDE pipelines list', () => {
},
actions: {
fetchLatestPipeline: fetchLatestPipelineMock,
+ pingUsage: pingUsageMock,
},
getters: {
jobsCount: () => 1,
@@ -77,6 +87,11 @@ describe('IDE pipelines list', () => {
expect(fetchLatestPipelineMock).toHaveBeenCalled();
});
+ it('pings pipeline usage', () => {
+ createComponent();
+ expect(IDEServices.pingUsage).toHaveBeenCalledWith(fakeProjectPath);
+ });
+
describe('when loading', () => {
let defaultPipelinesLoadingState;
beforeAll(() => {
diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js
index 0cde6fb6060..7b2025f5e9f 100644
--- a/spec/frontend/ide/components/preview/clientside_spec.js
+++ b/spec/frontend/ide/components/preview/clientside_spec.js
@@ -70,14 +70,6 @@ describe('IDE clientside preview', () => {
});
};
- beforeAll(() => {
- jest.useFakeTimers();
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
afterEach(() => {
wrapper.destroy();
});
diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js
index 5ea03eb1593..237be018807 100644
--- a/spec/frontend/ide/components/repo_commit_section_spec.js
+++ b/spec/frontend/ide/components/repo_commit_section_spec.js
@@ -36,7 +36,6 @@ describe('RepoCommitSection', () => {
}),
);
- store.state.rightPanelCollapsed = false;
store.state.currentBranch = 'master';
store.state.changedFiles = [];
store.state.stagedFiles = [{ ...files[0] }, { ...files[1] }];
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
new file mode 100644
index 00000000000..82ea73ffbb1
--- /dev/null
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -0,0 +1,185 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import repoTab from '~/ide/components/repo_tab.vue';
+import router from '~/ide/ide_router';
+import { file, resetStore } from '../helpers';
+
+describe('RepoTab', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const RepoTab = Vue.extend(repoTab);
+
+ return new RepoTab({
+ store,
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(() => {
+ jest.spyOn(router, 'push').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders a close link and a name link', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+ vm.$store.state.openFiles.push(vm.tab);
+ const close = vm.$el.querySelector('.multi-file-tab-close');
+ const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
+
+ expect(close.innerHTML).toContain('#close');
+ expect(name.textContent.trim()).toEqual(vm.tab.name);
+ });
+
+ it('does not call openPendingTab when tab is active', done => {
+ vm = createComponent({
+ tab: {
+ ...file(),
+ pending: true,
+ active: true,
+ },
+ });
+
+ jest.spyOn(vm, 'openPendingTab').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ vm.$nextTick(() => {
+ expect(vm.openPendingTab).not.toHaveBeenCalled();
+
+ done();
+ });
+ });
+
+ it('fires clickFile when the link is clicked', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ jest.spyOn(vm, 'clickFile').mockImplementation(() => {});
+
+ vm.$el.click();
+
+ expect(vm.clickFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('calls closeFile when clicking close button', () => {
+ vm = createComponent({
+ tab: file(),
+ });
+
+ jest.spyOn(vm, 'closeFile').mockImplementation(() => {});
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ expect(vm.closeFile).toHaveBeenCalledWith(vm.tab);
+ });
+
+ it('changes icon on hover', done => {
+ const tab = file();
+ tab.changed = true;
+ vm = createComponent({
+ tab,
+ });
+
+ vm.$el.dispatchEvent(new Event('mouseover'));
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vm.$el.querySelector('.file-modified')).toBeNull();
+
+ vm.$el.dispatchEvent(new Event('mouseout'));
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.file-modified')).not.toBeNull();
+
+ done();
+ })
+ .catch(done.fail);
+ });
+
+ describe('locked file', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file('locked file');
+ f.file_lock = {
+ user: {
+ name: 'testuser',
+ updated_at: new Date(),
+ },
+ };
+
+ vm = createComponent({
+ tab: f,
+ });
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders lock icon', () => {
+ expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
+ });
+
+ it('renders a tooltip', () => {
+ expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain(
+ 'Locked by testuser',
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('closeTab', () => {
+ it('closes tab if file has changed', done => {
+ const tab = file();
+ tab.changed = true;
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.changedFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+ expect(vm.$store.state.changedFiles.length).toBe(1);
+
+ done();
+ });
+ });
+
+ it('closes tab when clicking close btn', done => {
+ const tab = file('lose');
+ tab.opened = true;
+ vm = createComponent({
+ tab,
+ });
+ vm.$store.state.openFiles.push(tab);
+ vm.$store.state.entries[tab.path] = tab;
+ vm.$store.dispatch('setFileActive', tab.path);
+
+ vm.$el.querySelector('.multi-file-tab-close').click();
+
+ vm.$nextTick(() => {
+ expect(tab.opened).toBeFalsy();
+
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js
new file mode 100644
index 00000000000..583f71e6121
--- /dev/null
+++ b/spec/frontend/ide/components/repo_tabs_spec.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import repoTabs from '~/ide/components/repo_tabs.vue';
+import createComponent from '../../helpers/vue_mount_component_helper';
+import { file } from '../helpers';
+
+describe('RepoTabs', () => {
+ const openedFiles = [file('open1'), file('open2')];
+ const RepoTabs = Vue.extend(repoTabs);
+ let vm;
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders a list of tabs', done => {
+ vm = createComponent(RepoTabs, {
+ files: openedFiles,
+ viewer: 'editor',
+ hasChanges: false,
+ activeFile: file('activeFile'),
+ hasMergeRequest: false,
+ });
+ openedFiles[0].active = true;
+
+ vm.$nextTick(() => {
+ const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
+
+ expect(tabs.length).toEqual(2);
+ expect(tabs[0].parentNode.classList.contains('active')).toEqual(true);
+ expect(tabs[1].parentNode.classList.contains('active')).toEqual(false);
+
+ done();
+ });
+ });
+});
diff --git a/spec/frontend/ide/components/shared/tokened_input_spec.js b/spec/frontend/ide/components/shared/tokened_input_spec.js
new file mode 100644
index 00000000000..e687216bd06
--- /dev/null
+++ b/spec/frontend/ide/components/shared/tokened_input_spec.js
@@ -0,0 +1,133 @@
+import Vue from 'vue';
+import mountComponent from 'helpers/vue_mount_component_helper';
+import TokenedInput from '~/ide/components/shared/tokened_input.vue';
+
+const TEST_PLACEHOLDER = 'Searching in test';
+const TEST_TOKENS = [
+ { label: 'lorem', id: 1 },
+ { label: 'ipsum', id: 2 },
+ { label: 'dolar', id: 3 },
+];
+const TEST_VALUE = 'lorem';
+
+function getTokenElements(vm) {
+ return Array.from(vm.$el.querySelectorAll('.filtered-search-token button'));
+}
+
+function createBackspaceEvent() {
+ const e = new Event('keyup');
+ e.keyCode = 8;
+ e.which = e.keyCode;
+ e.altKey = false;
+ e.ctrlKey = true;
+ e.shiftKey = false;
+ e.metaKey = false;
+ return e;
+}
+
+describe('IDE shared/TokenedInput', () => {
+ const Component = Vue.extend(TokenedInput);
+ let vm;
+
+ beforeEach(() => {
+ vm = mountComponent(Component, {
+ tokens: TEST_TOKENS,
+ placeholder: TEST_PLACEHOLDER,
+ value: TEST_VALUE,
+ });
+
+ jest.spyOn(vm, '$emit').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('renders tokens', () => {
+ const renderedTokens = getTokenElements(vm).map(x => x.textContent.trim());
+
+ expect(renderedTokens).toEqual(TEST_TOKENS.map(x => x.label));
+ });
+
+ it('renders input', () => {
+ expect(vm.$refs.input).toBeTruthy();
+ expect(vm.$refs.input).toHaveValue(TEST_VALUE);
+ });
+
+ it('renders placeholder, when tokens are empty', done => {
+ vm.tokens = [];
+
+ vm.$nextTick()
+ .then(() => {
+ expect(vm.$refs.input).toHaveAttr('placeholder', TEST_PLACEHOLDER);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('triggers "removeToken" on token click', () => {
+ getTokenElements(vm)[0].click();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[0]);
+ });
+
+ it('when input triggers backspace event, it calls "onBackspace"', () => {
+ jest.spyOn(vm, 'onBackspace').mockImplementation(() => {});
+
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+ vm.$refs.input.dispatchEvent(createBackspaceEvent());
+
+ expect(vm.onBackspace).toHaveBeenCalledTimes(2);
+ });
+
+ it('triggers "removeToken" on backspaces when value is empty', () => {
+ vm.value = '';
+
+ vm.onBackspace();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ expect(vm.backspaceCount).toEqual(1);
+
+ vm.onBackspace();
+
+ expect(vm.$emit).toHaveBeenCalledWith('removeToken', TEST_TOKENS[TEST_TOKENS.length - 1]);
+ expect(vm.backspaceCount).toEqual(0);
+ });
+
+ it('does not trigger "removeToken" on backspaces when value is not empty', () => {
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('does not trigger "removeToken" on backspaces when tokens are empty', () => {
+ vm.tokens = [];
+
+ vm.onBackspace();
+ vm.onBackspace();
+
+ expect(vm.backspaceCount).toEqual(0);
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+
+ it('triggers "focus" on input focus', () => {
+ vm.$refs.input.dispatchEvent(new Event('focus'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('focus');
+ });
+
+ it('triggers "blur" on input blur', () => {
+ vm.$refs.input.dispatchEvent(new Event('blur'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('blur');
+ });
+
+ it('triggers "input" with value on input change', () => {
+ vm.$refs.input.value = 'something-else';
+ vm.$refs.input.dispatchEvent(new Event('input'));
+
+ expect(vm.$emit).toHaveBeenCalledWith('input', 'something-else');
+ });
+});
diff --git a/spec/frontend/ide/lib/common/model_manager_spec.js b/spec/frontend/ide/lib/common/model_manager_spec.js
new file mode 100644
index 00000000000..08e4ab0f113
--- /dev/null
+++ b/spec/frontend/ide/lib/common/model_manager_spec.js
@@ -0,0 +1,126 @@
+import eventHub from '~/ide/eventhub';
+import ModelManager from '~/ide/lib/common/model_manager';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model manager', () => {
+ let instance;
+
+ beforeEach(() => {
+ instance = new ModelManager();
+ });
+
+ afterEach(() => {
+ instance.dispose();
+ });
+
+ describe('addModel', () => {
+ it('caches model', () => {
+ instance.addModel(file());
+
+ expect(instance.models.size).toBe(1);
+ });
+
+ it('caches model by file path', () => {
+ const f = file('path-name');
+ instance.addModel(f);
+
+ expect(instance.models.keys().next().value).toBe(f.key);
+ });
+
+ it('adds model into disposable', () => {
+ jest.spyOn(instance.disposable, 'add');
+
+ instance.addModel(file());
+
+ expect(instance.disposable.add).toHaveBeenCalled();
+ });
+
+ it('returns cached model', () => {
+ jest.spyOn(instance.models, 'get');
+
+ instance.addModel(file());
+ instance.addModel(file());
+
+ expect(instance.models.get).toHaveBeenCalled();
+ });
+
+ it('adds eventHub listener', () => {
+ const f = file();
+ jest.spyOn(eventHub, '$on');
+
+ instance.addModel(f);
+
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('hasCachedModel', () => {
+ it('returns false when no models exist', () => {
+ expect(instance.hasCachedModel('path')).toBeFalsy();
+ });
+
+ it('returns true when model exists', () => {
+ const f = file('path-name');
+
+ instance.addModel(f);
+
+ expect(instance.hasCachedModel(f.key)).toBeTruthy();
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns cached model', () => {
+ instance.addModel(file('path-name'));
+
+ expect(instance.getModel('path-name')).not.toBeNull();
+ });
+ });
+
+ describe('removeCachedModel', () => {
+ let f;
+
+ beforeEach(() => {
+ f = file();
+
+ instance.addModel(f);
+ });
+
+ it('clears cached model', () => {
+ instance.removeCachedModel(f);
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ jest.spyOn(eventHub, '$off');
+
+ instance.removeCachedModel(f);
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${f.key}`,
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached models', () => {
+ instance.addModel(file());
+
+ instance.dispose();
+
+ expect(instance.models.size).toBe(0);
+ });
+
+ it('calls disposable dispose', () => {
+ jest.spyOn(instance.disposable, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js
new file mode 100644
index 00000000000..2ef2f0da6da
--- /dev/null
+++ b/spec/frontend/ide/lib/common/model_spec.js
@@ -0,0 +1,137 @@
+import eventHub from '~/ide/eventhub';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library model', () => {
+ let model;
+
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$on');
+
+ const f = file('path');
+ f.mrChange = { diff: 'ABC' };
+ f.baseRaw = 'test';
+ model = new Model(f);
+ });
+
+ afterEach(() => {
+ model.dispose();
+ });
+
+ it('creates original model & base model & new model', () => {
+ expect(model.originalModel).not.toBeNull();
+ expect(model.model).not.toBeNull();
+ expect(model.baseModel).not.toBeNull();
+
+ expect(model.originalModel.uri.path).toBe('original/path--path');
+ expect(model.model.uri.path).toBe('path--path');
+ expect(model.baseModel.uri.path).toBe('target/path--path');
+ });
+
+ it('creates model with head file to compare against', () => {
+ const f = file('path');
+ model.dispose();
+
+ model = new Model(f, {
+ ...f,
+ content: '123 testing',
+ });
+
+ expect(model.head).not.toBeNull();
+ expect(model.getOriginalModel().getValue()).toBe('123 testing');
+ });
+
+ it('adds eventHub listener', () => {
+ expect(eventHub.$on).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ expect.anything(),
+ );
+ });
+
+ describe('path', () => {
+ it('returns file path', () => {
+ expect(model.path).toBe(model.file.key);
+ });
+ });
+
+ describe('getModel', () => {
+ it('returns model', () => {
+ expect(model.getModel()).toBe(model.model);
+ });
+ });
+
+ describe('getOriginalModel', () => {
+ it('returns original model', () => {
+ expect(model.getOriginalModel()).toBe(model.originalModel);
+ });
+ });
+
+ describe('getBaseModel', () => {
+ it('returns base model', () => {
+ expect(model.getBaseModel()).toBe(model.baseModel);
+ });
+ });
+
+ describe('setValue', () => {
+ it('updates models value', () => {
+ model.setValue('testing 123');
+
+ expect(model.getModel().getValue()).toBe('testing 123');
+ });
+ });
+
+ describe('onChange', () => {
+ it('calls callback on change', done => {
+ const spy = jest.fn();
+ model.onChange(spy);
+
+ model.getModel().setValue('123');
+
+ setImmediate(() => {
+ expect(spy).toHaveBeenCalledWith(model, expect.anything());
+ done();
+ });
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ jest.spyOn(model.disposable, 'dispose');
+
+ model.dispose();
+
+ expect(model.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('clears events', () => {
+ model.onChange(() => {});
+
+ expect(model.events.size).toBe(1);
+
+ model.dispose();
+
+ expect(model.events.size).toBe(0);
+ });
+
+ it('removes eventHub listener', () => {
+ jest.spyOn(eventHub, '$off');
+
+ model.dispose();
+
+ expect(eventHub.$off).toHaveBeenCalledWith(
+ `editor.update.model.dispose.${model.file.key}`,
+ expect.anything(),
+ );
+ });
+
+ it('calls onDispose callback', () => {
+ const disposeSpy = jest.fn();
+
+ model.onDispose(disposeSpy);
+
+ model.dispose();
+
+ expect(disposeSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/decorations/controller_spec.js b/spec/frontend/ide/lib/decorations/controller_spec.js
new file mode 100644
index 00000000000..4556fc9d646
--- /dev/null
+++ b/spec/frontend/ide/lib/decorations/controller_spec.js
@@ -0,0 +1,143 @@
+import Editor from '~/ide/lib/editor';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import Model from '~/ide/lib/common/model';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library decorations controller', () => {
+ let editorInstance;
+ let controller;
+ let model;
+
+ beforeEach(() => {
+ editorInstance = Editor.create();
+ editorInstance.createInstance(document.createElement('div'));
+
+ controller = new DecorationsController(editorInstance);
+ model = new Model(file('path'));
+ });
+
+ afterEach(() => {
+ model.dispose();
+ editorInstance.dispose();
+ controller.dispose();
+ });
+
+ describe('getAllDecorationsForModel', () => {
+ it('returns empty array when no decorations exist for model', () => {
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations).toEqual([]);
+ });
+
+ it('returns decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ const decorations = controller.getAllDecorationsForModel(model);
+
+ expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
+ });
+ });
+
+ describe('addDecorations', () => {
+ it('caches decorations in a new map', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('does not create new cache model', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorations.size).toBe(1);
+ expect(controller.decorations.keys().next().value).toBe('gitlab:path--path');
+ });
+
+ it('calls decorate method', () => {
+ jest.spyOn(controller, 'decorate').mockImplementation(() => {});
+
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.decorate).toHaveBeenCalled();
+ });
+ });
+
+ describe('decorate', () => {
+ it('sets decorations on editor instance', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockImplementation(() => {});
+
+ controller.decorate(model);
+
+ expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
+ });
+
+ it('caches decorations', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.size).toBe(1);
+ });
+
+ it('caches decorations by model URL', () => {
+ jest.spyOn(controller.editor.instance, 'deltaDecorations').mockReturnValue([]);
+
+ controller.decorate(model);
+
+ expect(controller.editorDecorations.keys().next().value).toBe('gitlab:path--path');
+ });
+ });
+
+ describe('dispose', () => {
+ it('clears cached decorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.decorations.size).toBe(0);
+ });
+
+ it('clears cached editorDecorations', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ controller.dispose();
+
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+
+ describe('hasDecorations', () => {
+ it('returns true when decorations are cached', () => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+
+ expect(controller.hasDecorations(model)).toBe(true);
+ });
+
+ it('returns false when no model decorations exist', () => {
+ expect(controller.hasDecorations(model)).toBe(false);
+ });
+ });
+
+ describe('removeDecorations', () => {
+ beforeEach(() => {
+ controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
+ controller.decorate(model);
+ });
+
+ it('removes cached decorations', () => {
+ expect(controller.decorations.size).not.toBe(0);
+ expect(controller.editorDecorations.size).not.toBe(0);
+
+ controller.removeDecorations(model);
+
+ expect(controller.decorations.size).toBe(0);
+ expect(controller.editorDecorations.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/diff/controller_spec.js b/spec/frontend/ide/lib/diff/controller_spec.js
new file mode 100644
index 00000000000..0b33a4c6ad6
--- /dev/null
+++ b/spec/frontend/ide/lib/diff/controller_spec.js
@@ -0,0 +1,215 @@
+import { Range } from 'monaco-editor';
+import Editor from '~/ide/lib/editor';
+import ModelManager from '~/ide/lib/common/model_manager';
+import DecorationsController from '~/ide/lib/decorations/controller';
+import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller';
+import { computeDiff } from '~/ide/lib/diff/diff';
+import { file } from '../../helpers';
+
+describe('Multi-file editor library dirty diff controller', () => {
+ let editorInstance;
+ let controller;
+ let modelManager;
+ let decorationsController;
+ let model;
+
+ beforeEach(() => {
+ editorInstance = Editor.create();
+ editorInstance.createInstance(document.createElement('div'));
+
+ modelManager = new ModelManager();
+ decorationsController = new DecorationsController(editorInstance);
+
+ model = modelManager.addModel(file('path'));
+
+ controller = new DirtyDiffController(modelManager, decorationsController);
+ });
+
+ afterEach(() => {
+ controller.dispose();
+ model.dispose();
+ decorationsController.dispose();
+ editorInstance.dispose();
+ });
+
+ describe('getDiffChangeType', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDiffChangeType(change)).toBe(type);
+ });
+ });
+ });
+
+ describe('getDecorator', () => {
+ ['added', 'removed', 'modified'].forEach(type => {
+ it(`returns with linesDecorationsClassName for ${type}`, () => {
+ const change = {
+ [type]: true,
+ };
+
+ expect(getDecorator(change).options.linesDecorationsClassName).toBe(
+ `dirty-diff dirty-diff-${type}`,
+ );
+ });
+
+ it('returns with line numbers', () => {
+ const change = {
+ lineNumber: 1,
+ endLineNumber: 2,
+ [type]: true,
+ };
+
+ const { range } = getDecorator(change);
+
+ expect(range.startLineNumber).toBe(1);
+ expect(range.endLineNumber).toBe(2);
+ expect(range.startColumn).toBe(1);
+ expect(range.endColumn).toBe(1);
+ });
+ });
+ });
+
+ describe('attachModel', () => {
+ it('adds change event callback', () => {
+ jest.spyOn(model, 'onChange').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ expect(model.onChange).toHaveBeenCalled();
+ });
+
+ it('adds dispose event callback', () => {
+ jest.spyOn(model, 'onDispose').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ expect(model.onDispose).toHaveBeenCalled();
+ });
+
+ it('calls throttledComputeDiff on change', () => {
+ jest.spyOn(controller, 'throttledComputeDiff').mockImplementation(() => {});
+
+ controller.attachModel(model);
+
+ model.getModel().setValue('123');
+
+ expect(controller.throttledComputeDiff).toHaveBeenCalled();
+ });
+
+ it('caches model', () => {
+ controller.attachModel(model);
+
+ expect(controller.models.has(model.url)).toBe(true);
+ });
+ });
+
+ describe('computeDiff', () => {
+ it('posts to worker', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'postMessage').mockImplementation(() => {});
+
+ controller.computeDiff(model);
+
+ expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
+ path: model.path,
+ originalContent: '',
+ newContent: '',
+ });
+ });
+ });
+
+ describe('reDecorate', () => {
+ it('calls computeDiff when no decorations are cached', () => {
+ jest.spyOn(controller, 'computeDiff').mockImplementation(() => {});
+
+ controller.reDecorate(model);
+
+ expect(controller.computeDiff).toHaveBeenCalledWith(model);
+ });
+
+ it('calls decorate when decorations are cached', () => {
+ jest.spyOn(controller.decorationsController, 'decorate').mockImplementation(() => {});
+
+ controller.decorationsController.decorations.set(model.url, 'test');
+
+ controller.reDecorate(model);
+
+ expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('decorate', () => {
+ it('adds decorations into decorations controller', () => {
+ jest.spyOn(controller.decorationsController, 'addDecorations').mockImplementation(() => {});
+
+ controller.decorate({ data: { changes: [], path: model.path } });
+
+ expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(
+ model,
+ 'dirtyDiff',
+ expect.anything(),
+ );
+ });
+
+ it('adds decorations into editor', () => {
+ const spy = jest.spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
+
+ controller.decorate({
+ data: { changes: computeDiff('123', '1234'), path: model.path },
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ [],
+ [
+ {
+ range: new Range(1, 1, 1, 1),
+ options: {
+ isWholeLine: true,
+ linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
+ },
+ },
+ ],
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposable dispose', () => {
+ jest.spyOn(controller.disposable, 'dispose');
+
+ controller.dispose();
+
+ expect(controller.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('terminates worker', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'terminate');
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
+ });
+
+ it('removes worker event listener', () => {
+ jest.spyOn(controller.dirtyDiffWorker, 'removeEventListener');
+
+ controller.dispose();
+
+ expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith(
+ 'message',
+ expect.anything(),
+ );
+ });
+
+ it('clears cached models', () => {
+ controller.attachModel(model);
+
+ model.dispose();
+
+ expect(controller.models.size).toBe(0);
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js
new file mode 100644
index 00000000000..36d4c3c26ee
--- /dev/null
+++ b/spec/frontend/ide/lib/editor_spec.js
@@ -0,0 +1,302 @@
+import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
+import Editor from '~/ide/lib/editor';
+import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { file } from '../helpers';
+
+describe('Multi-file editor library', () => {
+ let instance;
+ let el;
+ let holder;
+
+ const setNodeOffsetWidth = val => {
+ Object.defineProperty(instance.instance.getDomNode(), 'offsetWidth', {
+ get() {
+ return val;
+ },
+ });
+ };
+
+ beforeEach(() => {
+ el = document.createElement('div');
+ holder = document.createElement('div');
+ el.appendChild(holder);
+
+ document.body.appendChild(el);
+
+ instance = Editor.create();
+ });
+
+ afterEach(() => {
+ instance.modelManager.dispose();
+ instance.dispose();
+ Editor.editorInstance = null;
+
+ el.remove();
+ });
+
+ it('creates instance of editor', () => {
+ expect(Editor.editorInstance).not.toBeNull();
+ });
+
+ it('creates instance returns cached instance', () => {
+ expect(Editor.create()).toEqual(instance);
+ });
+
+ describe('createInstance', () => {
+ it('creates editor instance', () => {
+ jest.spyOn(monacoEditor, 'create');
+
+ instance.createInstance(holder);
+
+ expect(monacoEditor.create).toHaveBeenCalled();
+ });
+
+ it('creates dirty diff controller', () => {
+ instance.createInstance(holder);
+
+ expect(instance.dirtyDiffController).not.toBeNull();
+ });
+
+ it('creates model manager', () => {
+ instance.createInstance(holder);
+
+ expect(instance.modelManager).not.toBeNull();
+ });
+ });
+
+ describe('createDiffInstance', () => {
+ it('creates editor instance', () => {
+ jest.spyOn(monacoEditor, 'createDiffEditor');
+
+ instance.createDiffInstance(holder);
+
+ expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, {
+ ...defaultEditorOptions,
+ quickSuggestions: false,
+ occurrencesHighlight: false,
+ renderSideBySide: false,
+ readOnly: true,
+ renderLineHighlight: 'all',
+ hideCursorInOverviewRuler: false,
+ });
+ });
+ });
+
+ describe('createModel', () => {
+ it('calls model manager addModel', () => {
+ jest.spyOn(instance.modelManager, 'addModel').mockImplementation(() => {});
+
+ instance.createModel('FILE');
+
+ expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE', null);
+ });
+ });
+
+ describe('attachModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createInstance(document.createElement('div'));
+
+ model = instance.createModel(file());
+ });
+
+ it('sets the current model on the instance', () => {
+ instance.attachModel(model);
+
+ expect(instance.currentModel).toBe(model);
+ });
+
+ it('attaches the model to the current instance', () => {
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
+ });
+
+ it('sets original & modified when diff editor', () => {
+ jest.spyOn(instance.instance, 'getEditorType').mockReturnValue('vs.editor.IDiffEditor');
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getOriginalModel(),
+ modified: model.getModel(),
+ });
+ });
+
+ it('attaches the model to the dirty diff controller', () => {
+ jest.spyOn(instance.dirtyDiffController, 'attachModel').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
+ });
+
+ it('re-decorates with the dirty diff controller', () => {
+ jest.spyOn(instance.dirtyDiffController, 'reDecorate').mockImplementation(() => {});
+
+ instance.attachModel(model);
+
+ expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('attachMergeRequestModel', () => {
+ let model;
+
+ beforeEach(() => {
+ instance.createDiffInstance(document.createElement('div'));
+
+ const f = file();
+ f.mrChanges = { diff: 'ABC' };
+ f.baseRaw = 'testing';
+
+ model = instance.createModel(f);
+ });
+
+ it('sets original & modified', () => {
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.attachMergeRequestModel(model);
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith({
+ original: model.getBaseModel(),
+ modified: model.getModel(),
+ });
+ });
+ });
+
+ describe('clearEditor', () => {
+ it('resets the editor model', () => {
+ instance.createInstance(document.createElement('div'));
+
+ jest.spyOn(instance.instance, 'setModel').mockImplementation(() => {});
+
+ instance.clearEditor();
+
+ expect(instance.instance.setModel).toHaveBeenCalledWith(null);
+ });
+ });
+
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls disposble dispose method', () => {
+ jest.spyOn(instance.disposable, 'dispose');
+
+ instance.dispose();
+
+ expect(instance.disposable.dispose).toHaveBeenCalled();
+ });
+
+ it('resets instance', () => {
+ instance.createInstance(document.createElement('div'));
+
+ expect(instance.instance).not.toBeNull();
+
+ instance.dispose();
+
+ expect(instance.instance).toBeNull();
+ });
+
+ it('does not dispose modelManager', () => {
+ jest.spyOn(instance.modelManager, 'dispose').mockImplementation(() => {});
+
+ instance.dispose();
+
+ expect(instance.modelManager.dispose).not.toHaveBeenCalled();
+ });
+
+ it('does not dispose decorationsController', () => {
+ jest.spyOn(instance.decorationsController, 'dispose').mockImplementation(() => {});
+
+ instance.dispose();
+
+ expect(instance.decorationsController.dispose).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('updateDiffView', () => {
+ describe('edit mode', () => {
+ it('does not update options', () => {
+ instance.createInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions').mockImplementation(() => {});
+
+ instance.updateDiffView();
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('diff mode', () => {
+ beforeEach(() => {
+ instance.createDiffInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions');
+ });
+
+ it('sets renderSideBySide to false if el is less than 700 pixels', () => {
+ setNodeOffsetWidth(600);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: false,
+ });
+ });
+
+ it('sets renderSideBySide to false if el is more than 700 pixels', () => {
+ setNodeOffsetWidth(800);
+
+ expect(instance.instance.updateOptions).not.toHaveBeenCalledWith({
+ renderSideBySide: true,
+ });
+ });
+ });
+ });
+
+ describe('isDiffEditorType', () => {
+ it('returns true when diff editor', () => {
+ instance.createDiffInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(true);
+ });
+
+ it('returns false when not diff editor', () => {
+ instance.createInstance(holder);
+
+ expect(instance.isDiffEditorType).toBe(false);
+ });
+ });
+
+ it('sets quickSuggestions to false when language is markdown', () => {
+ instance.createInstance(holder);
+
+ jest.spyOn(instance.instance, 'updateOptions');
+
+ const model = instance.createModel({
+ ...file(),
+ key: 'index.md',
+ path: 'index.md',
+ });
+
+ instance.attachModel(model);
+
+ expect(instance.instance.updateOptions).toHaveBeenCalledWith({
+ readOnly: false,
+ quickSuggestions: false,
+ });
+ });
+});
diff --git a/spec/frontend/ide/lib/languages/vue_spec.js b/spec/frontend/ide/lib/languages/vue_spec.js
new file mode 100644
index 00000000000..3d8784c1436
--- /dev/null
+++ b/spec/frontend/ide/lib/languages/vue_spec.js
@@ -0,0 +1,92 @@
+import { editor } from 'monaco-editor';
+import { registerLanguages } from '~/ide/utils';
+import vue from '~/ide/lib/languages/vue';
+
+// This file only tests syntax specific to vue. This does not test existing syntaxes
+// of html, javascript, css and handlebars, which vue files extend.
+describe('tokenization for .vue files', () => {
+ beforeEach(() => {
+ registerLanguages(vue);
+ });
+
+ test.each([
+ [
+ '<div v-if="something">content</div>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 4, type: '' },
+ { language: 'vue', offset: 5, type: 'variable' },
+ { language: 'vue', offset: 21, type: 'delimiter.html' },
+ { language: 'vue', offset: 22, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 34, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<input :placeholder="placeholder">',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 6, type: '' },
+ { language: 'vue', offset: 7, type: 'variable' },
+ { language: 'vue', offset: 33, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<gl-modal @ok="submitForm()"></gl-modal>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 3, type: 'attribute.name' },
+ { language: 'vue', offset: 9, type: '' },
+ { language: 'vue', offset: 10, type: 'variable' },
+ { language: 'vue', offset: 28, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 33, type: 'attribute.name' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a v-on:click.stop="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ { language: 'vue', offset: 33, type: '' },
+ { language: 'vue', offset: 36, type: 'delimiter.html' },
+ { language: 'vue', offset: 38, type: 'tag.html' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a @[event]="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 25, type: 'delimiter.html' },
+ { language: 'vue', offset: 26, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ ])('%s', (string, tokens) => {
+ expect(editor.tokenize(string, 'vue')).toEqual(tokens);
+ });
+});
diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js
index 658ad37d7f2..3cb6e064aa2 100644
--- a/spec/frontend/ide/services/index_spec.js
+++ b/spec/frontend/ide/services/index_spec.js
@@ -221,4 +221,67 @@ describe('IDE services', () => {
});
});
});
+
+ describe('getFiles', () => {
+ let mock;
+ let relativeUrlRoot;
+ const TEST_RELATIVE_URL_ROOT = 'blah-blah';
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+ relativeUrlRoot = gon.relative_url_root;
+ gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
+
+ mock = new MockAdapter(axios);
+
+ mock
+ .onGet(`${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`)
+ .reply(200, [TEST_FILE_PATH]);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ gon.relative_url_root = relativeUrlRoot;
+ });
+
+ it('initates the api call based on the passed path and commit hash', () => {
+ return services.getFiles(TEST_PROJECT_ID, TEST_COMMIT_SHA).then(({ data }) => {
+ expect(axios.get).toHaveBeenCalledWith(
+ `${gon.relative_url_root}/${TEST_PROJECT_ID}/-/files/${TEST_COMMIT_SHA}`,
+ expect.any(Object),
+ );
+ expect(data).toEqual([TEST_FILE_PATH]);
+ });
+ });
+ });
+
+ describe('pingUsage', () => {
+ let mock;
+ let relativeUrlRoot;
+ const TEST_RELATIVE_URL_ROOT = 'blah-blah';
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'post');
+ relativeUrlRoot = gon.relative_url_root;
+ gon.relative_url_root = TEST_RELATIVE_URL_ROOT;
+
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ gon.relative_url_root = relativeUrlRoot;
+ });
+
+ it('posts to usage endpoint', () => {
+ const TEST_PROJECT_PATH = 'foo/bar';
+ const axiosURL = `${TEST_RELATIVE_URL_ROOT}/${TEST_PROJECT_PATH}/usage_ping/web_ide_pipelines_count`;
+
+ mock.onPost(axiosURL).reply(200);
+
+ return services.pingUsage(TEST_PROJECT_PATH).then(() => {
+ expect(axios.post).toHaveBeenCalledWith(axiosURL);
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js
index 5d0fe35a10e..2eca9acb8d8 100644
--- a/spec/frontend/ide/stores/mutations_spec.js
+++ b/spec/frontend/ide/stores/mutations_spec.js
@@ -55,30 +55,6 @@ describe('Multi-file store mutations', () => {
});
});
- describe('SET_LEFT_PANEL_COLLAPSED', () => {
- it('sets left panel collapsed', () => {
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.leftPanelCollapsed).toBeTruthy();
-
- mutations.SET_LEFT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.leftPanelCollapsed).toBeFalsy();
- });
- });
-
- describe('SET_RIGHT_PANEL_COLLAPSED', () => {
- it('sets right panel collapsed', () => {
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true);
-
- expect(localState.rightPanelCollapsed).toBeTruthy();
-
- mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false);
-
- expect(localState.rightPanelCollapsed).toBeFalsy();
- });
- });
-
describe('CLEAR_STAGED_CHANGES', () => {
it('clears stagedFiles array', () => {
localState.stagedFiles.push('a');
@@ -339,23 +315,6 @@ describe('Multi-file store mutations', () => {
});
});
- describe('OPEN_NEW_ENTRY_MODAL', () => {
- it('sets entryModal', () => {
- localState.entries.testPath = file();
-
- mutations.OPEN_NEW_ENTRY_MODAL(localState, {
- type: 'test',
- path: 'testPath',
- });
-
- expect(localState.entryModal).toEqual({
- type: 'test',
- path: 'testPath',
- entry: localState.entries.testPath,
- });
- });
- });
-
describe('RENAME_ENTRY', () => {
beforeEach(() => {
localState.trees = {
diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js
index 90f2644de62..b87f6c1f05a 100644
--- a/spec/frontend/ide/stores/utils_spec.js
+++ b/spec/frontend/ide/stores/utils_spec.js
@@ -685,4 +685,75 @@ describe('Multi-file store utils', () => {
});
});
});
+
+ describe('extractMarkdownImagesFromEntries', () => {
+ let mdFile;
+ let entries;
+
+ beforeEach(() => {
+ const img = { content: '/base64/encoded/image+' };
+ mdFile = { path: 'path/to/some/directory/myfile.md' };
+ entries = {
+ // invalid (or lack of) extensions are also supported as long as there's
+ // a real image inside and can go into an <img> tag's `src` and the browser
+ // can render it
+ img,
+ 'img.js': img,
+ 'img.png': img,
+ 'img.with.many.dots.png': img,
+ 'path/to/img.gif': img,
+ 'path/to/some/img.jpg': img,
+ 'path/to/some/img 1/img.png': img,
+ 'path/to/some/directory/img.png': img,
+ 'path/to/some/directory/img 1.png': img,
+ };
+ });
+
+ it.each`
+ markdownBefore | ext | imgAlt | imgTitle
+ ${'* ![img](/img)'} | ${'jpeg'} | ${'img'} | ${undefined}
+ ${'* ![img](/img.js)'} | ${'js'} | ${'img'} | ${undefined}
+ ${'* ![img](img.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](./img.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![with spaces](../img 1/img.png)'} | ${'png'} | ${'with spaces'} | ${undefined}
+ ${'* ![img](../../img.gif " title ")'} | ${'gif'} | ${'img'} | ${' title '}
+ ${'* ![img](../img.jpg)'} | ${'jpg'} | ${'img'} | ${undefined}
+ ${'* ![img](/img.png "title")'} | ${'png'} | ${'img'} | ${'title'}
+ ${'* ![img](/img.with.many.dots.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](img 1.png)'} | ${'png'} | ${'img'} | ${undefined}
+ ${'* ![img](img.png "title here")'} | ${'png'} | ${'img'} | ${'title here'}
+ `(
+ 'correctly transforms markdown with uncommitted images: $markdownBefore',
+ ({ markdownBefore, ext, imgAlt, imgTitle }) => {
+ mdFile.content = markdownBefore;
+
+ expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
+ content: '* {{gl_md_img_1}}',
+ images: {
+ '{{gl_md_img_1}}': {
+ src: `data:image/${ext};base64,/base64/encoded/image+`,
+ alt: imgAlt,
+ title: imgTitle,
+ },
+ },
+ });
+ },
+ );
+
+ it.each`
+ markdown
+ ${'* ![img](i.png)'}
+ ${'* ![img](img.png invalid title)'}
+ ${'* ![img](img.png "incorrect" "markdown")'}
+ ${'* ![img](https://gitlab.com/logo.png)'}
+ ${'* ![img](https://gitlab.com/some/deep/nested/path/logo.png)'}
+ `("doesn't touch invalid or non-existant images in markdown: $markdown", ({ markdown }) => {
+ mdFile.content = markdown;
+
+ expect(utils.extractMarkdownImagesFromEntries(mdFile, entries)).toEqual({
+ content: markdown,
+ images: {},
+ });
+ });
+ });
});
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 44eae7eacbe..ea975500e8d 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -1,6 +1,7 @@
import { commitItemIconMap } from '~/ide/constants';
-import { getCommitIconMap, isTextFile } from '~/ide/utils';
+import { getCommitIconMap, isTextFile, registerLanguages, trimPathComponents } from '~/ide/utils';
import { decorateData } from '~/ide/stores/utils';
+import { languages } from 'monaco-editor';
describe('WebIDE utils', () => {
describe('isTextFile', () => {
@@ -102,4 +103,93 @@ describe('WebIDE utils', () => {
expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified);
});
});
+
+ describe('trimPathComponents', () => {
+ it.each`
+ input | output
+ ${'example path '} | ${'example path'}
+ ${'p/somefile '} | ${'p/somefile'}
+ ${'p /somefile '} | ${'p/somefile'}
+ ${'p/ somefile '} | ${'p/somefile'}
+ ${' p/somefile '} | ${'p/somefile'}
+ ${'p/somefile .md'} | ${'p/somefile .md'}
+ ${'path / to / some/file.doc '} | ${'path/to/some/file.doc'}
+ `('trims all path components in path: "$input"', ({ input, output }) => {
+ expect(trimPathComponents(input)).toEqual(output);
+ });
+ });
+
+ describe('registerLanguages', () => {
+ let langs;
+
+ beforeEach(() => {
+ langs = [
+ {
+ id: 'html',
+ extensions: ['.html'],
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'css',
+ extensions: ['.css'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'js',
+ extensions: ['.js'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ ];
+
+ jest.spyOn(languages, 'register').mockImplementation(() => {});
+ jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {});
+ jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {});
+ });
+
+ it('registers all the passed languages with Monaco', () => {
+ registerLanguages(...langs);
+
+ expect(languages.register.mock.calls).toEqual([
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.css'],
+ id: 'css',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.js'],
+ id: 'js',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ extensions: ['.html'],
+ id: 'html',
+ language: { tokenizer: {} },
+ },
+ ],
+ ]);
+
+ expect(languages.setMonarchTokensProvider.mock.calls).toEqual([
+ ['css', { tokenizer: {} }],
+ ['js', { tokenizer: {} }],
+ ['html', { tokenizer: {} }],
+ ]);
+
+ expect(languages.setLanguageConfiguration.mock.calls).toEqual([
+ ['css', { comments: { blockComment: ['/*', '*/'] } }],
+ ['js', { comments: { blockComment: ['/*', '*/'] } }],
+ ['html', { comments: { blockComment: ['<!--', '-->'] } }],
+ ]);
+ });
+ });
});