diff options
author | Phil Hughes <me@iamphill.com> | 2018-03-20 14:12:48 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-03-20 14:12:48 +0000 |
commit | f527e6e1855f30cf5ca5cb834b2d20438299a70e (patch) | |
tree | 9d5dd3e33f86cf43c0b445822341896e20027f94 /spec/javascripts | |
parent | 68b914c9371e6e3fffc6afde23ca77a77ca688f2 (diff) | |
download | gitlab-ce-f527e6e1855f30cf5ca5cb834b2d20438299a70e.tar.gz |
Move IDE to CE
This also makes the IDE generally available
Diffstat (limited to 'spec/javascripts')
43 files changed, 4344 insertions, 0 deletions
diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js new file mode 100644 index 00000000000..8f796b2f7f5 --- /dev/null +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import changedFileIcon from 'ee/ide/components/changed_file_icon.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE changed file icon', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(changedFileIcon); + + vm = createComponent(component, { + file: { + tempFile: false, + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('changedIcon', () => { + it('equals file-modified when not a temp file', () => { + expect(vm.changedIcon).toBe('file-modified'); + }); + + it('equals file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIcon).toBe('file-addition'); + }); + }); + + describe('changedIconClass', () => { + it('includes multi-file-modified when not a temp file', () => { + expect(vm.changedIconClass).toContain('multi-file-modified'); + }); + + it('includes multi-file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIconClass).toContain('multi-file-addition'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js new file mode 100644 index 00000000000..47a9007a8d0 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import commitActions from 'ee/ide/components/commit_sidebar/actions.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar actions', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(commitActions); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.currentBranchId = 'master'; + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 groups', () => { + expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3); + }); + + it('renders current branch text', () => { + expect(vm.$el.textContent).toContain('Commit to master branch'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js new file mode 100644 index 00000000000..f6789bc861f --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import listCollapsed from 'ee/ide/components/commit_sidebar/list_collapsed.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list collapsed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(listCollapsed); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.changedFiles.push(file('file1'), file('file2')); + vm.$store.state.changedFiles[0].tempFile = true; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders added & modified files count', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js new file mode 100644 index 00000000000..543299950ea --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -0,0 +1,83 @@ +import Vue from 'vue'; +import listItem from 'ee/ide/components/commit_sidebar/list_item.vue'; +import router from 'ee/ide/ide_router'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list item', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(listItem); + + f = file('test-file'); + + vm = mountComponent(Component, { + file: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file path', () => { + expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); + }); + + it('calls discardFileChanges when clicking discard button', () => { + spyOn(vm, 'discardFileChanges'); + + vm.$el.querySelector('.multi-file-discard-btn').click(); + + expect(vm.discardFileChanges).toHaveBeenCalled(); + }); + + it('opens a closed file in the editor when clicking the file path', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.openFileInEditor).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + }); + + it('calls updateViewer with diff when clicking file', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + }); + + describe('computed', () => { + describe('iconName', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconName).toBe('file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconName).toBe('file-addition'); + }); + }); + + describe('iconClass', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconClass).toContain('multi-file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconClass).toContain('multi-file-addition'); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js new file mode 100644 index 00000000000..f02d055e38c --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import commitSidebarList from 'ee/ide/components/commit_sidebar/list.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(commitSidebarList); + + vm = createComponentWithStore(Component, store, { + title: 'Staged', + fileList: [], + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with a list of files', () => { + beforeEach((done) => { + const f = file('file name'); + f.changed = true; + vm.fileList.push(f); + + Vue.nextTick(done); + }); + + it('renders list', () => { + expect(vm.$el.querySelectorAll('li').length).toBe(1); + }); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('hides list', () => { + expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); + expect(vm.$el.querySelector('.help-block')).toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..1058cc28de2 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import radioGroup from 'ee/ide/components/commit_sidebar/radio_group.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +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, + template: ` + <radio-group + value="1" + > + Testing slot + </radio-group> + `, + }); + + 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(); + }); + }); + + it('renders helpText tooltip', (done) => { + vm.helpText = 'help text'; + + Vue.nextTick(() => { + const help = vm.$el.querySelector('.help-block'); + + expect(help).not.toBeNull(); + expect(help.getAttribute('data-original-title')).toBe('help text'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach((done) => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + + 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(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js new file mode 100644 index 00000000000..9fa2e947db2 --- /dev/null +++ b/spec/javascripts/ide/components/ide_context_bar_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ideContextBar from 'ee/ide/components/ide_context_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; + +describe('Multi-file editor right context bar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideContextBar); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.$store.state.rightPanelCollapsed = true; + + Vue.nextTick(done); + }); + + it('adds collapsed class', () => { + expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js new file mode 100644 index 00000000000..b8da6747653 --- /dev/null +++ b/spec/javascripts/ide/components/ide_external_links_spec.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import ideExternalLinks from 'ee/ide/components/ide_external_links.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('ide external links component', () => { + let vm; + let fakeReferrer; + let Component; + + const fakeProjectUrl = '/project/'; + + beforeEach(() => { + Component = Vue.extend(ideExternalLinks); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('goBackUrl', () => { + it('renders the Go Back link with the referrer when present', () => { + fakeReferrer = '/example/README.md'; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeReferrer); + }); + + it('renders the Go Back link with the project url when referrer is not present', () => { + fakeReferrer = ''; + spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer); + + vm = createComponent(Component, { + projectUrl: fakeProjectUrl, + }).$mount(); + + expect(vm.goBackUrl).toEqual(fakeProjectUrl); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js new file mode 100644 index 00000000000..e7188490f64 --- /dev/null +++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import ideRepoTree from 'ee/ide/components/ide_repo_tree.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('IdeRepoTree', () => { + let vm; + let tree; + + beforeEach(() => { + const IdeRepoTree = Vue.extend(ideRepoTree); + + tree = { + tree: [file()], + loading: false, + }; + + vm = createComponent(IdeRepoTree, { + tree, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a sidebar', () => { + expect(vm.$el.querySelector('.loading-file')).toBeNull(); + expect(vm.$el.querySelector('.file')).not.toBeNull(); + }); + + it('renders 3 loading files if tree is loading', (done) => { + tree.loading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toEqual(3); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..74afca280d1 --- /dev/null +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ideSidebar from 'ee/ide/components/ide_side_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + 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(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js new file mode 100644 index 00000000000..7f8dcd9049f --- /dev/null +++ b/spec/javascripts/ide/components/ide_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import ide from 'ee/ide/components/ide.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('ide component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ide); + + vm = createComponentWithStore(Component, store, { + emptyStateSvgPath: 'svg', + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('does not render panel right when no files open', () => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + }); + + it('renders panel right when files are open', (done) => { + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [file()], + }; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..cba27f94833 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import newDropdown from 'ee/ide/components/new_dropdown/index.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.path = ''; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders new file, upload and new directory links', () => { + expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe( + 'Upload file', + ); + expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe( + 'New directory', + ); + }); + + describe('createNewItem', () => { + it('sets modalType to blob when new file is clicked', () => { + vm.$el.querySelectorAll('a')[0].click(); + + expect(vm.modalType).toBe('blob'); + }); + + it('sets modalType to tree when new directory is clicked', () => { + vm.$el.querySelectorAll('a')[2].click(); + + expect(vm.modalType).toBe('tree'); + }); + + it('opens modal when link is clicked', done => { + vm.$el.querySelectorAll('a')[0].click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + + done(); + }); + }); + }); + + describe('hideModal', () => { + beforeAll(done => { + vm.openModal = true; + Vue.nextTick(done); + }); + + it('closes modal after toggling', done => { + vm.hideModal(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.modal')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..1a9c96c64da --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import modal from 'ee/ide/components/new_dropdown/modal.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + ['tree', 'blob'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + vm = createComponent(Component, { + type, + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + }); + + it(`sets modal title as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`); + }); + + it(`sets button label as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`); + }); + + it(`sets form label as ${type}`, () => { + const title = type === 'tree' ? 'Directory' : 'File'; + + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); + }); + + describe('createEntryInStore', () => { + it('$emits create', () => { + spyOn(vm, '$emit'); + + vm.createEntryInStore(); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + branchId: 'master', + name: 'testing', + type, + }); + }); + }); + }); + }); + + it('focuses field on mount', () => { + document.body.innerHTML += '<div class="js-test"></div>'; + + vm = createComponent(Component, { + type: 'tree', + branchId: 'master', + path: '', + }, '.js-test'); + + expect(document.activeElement).toBe(vm.$refs.fieldName); + + vm.$el.remove(); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..766e8b72360 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,87 @@ +import Vue from 'vue'; +import upload from 'ee/ide/components/new_dropdown/upload.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + + spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('readFile', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText'); + spyOn(FileReader.prototype, 'readAsDataURL'); + }); + + it('calls readAsText for text files', () => { + const file = { + type: 'text/html', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file); + }); + + it('calls readAsDataURL for non-text files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const target = { + result: 'content', + }; + const binaryTarget = { + result: 'base64,base64content', + }; + const file = { + name: 'file', + }; + + it('creates new file', () => { + vm.createFile(target, file, true); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: target.result, + base64: false, + }); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, file, false); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js new file mode 100644 index 00000000000..8090e3664e0 --- /dev/null +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -0,0 +1,154 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import repoCommitSection from 'ee/ide/components/repo_commit_section.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoCommitSection', () => { + let vm; + + function createComponent() { + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'commitsvg', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.currentBranchId = 'master'; + vm.$store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + + vm.$store.state.rightPanelCollapsed = false; + vm.$store.state.currentBranch = 'master'; + vm.$store.state.changedFiles = [file('file1'), file('file2')]; + vm.$store.state.changedFiles.forEach(f => Object.assign(f, { + changed: true, + content: 'testing', + })); + + return vm.$mount(); + } + + beforeEach((done) => { + vm = createComponent(); + + spyOn(service, 'getTreeData').and.returnValue(Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), + })); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('empty Stage', () => { + it('renders no changes text', () => { + resetStore(vm.$store); + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'nochangessvg', + committedStateSvgPath: 'svg', + }).$mount(); + + expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); + expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg'); + }); + }); + + it('renders a commit section', () => { + const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')]; + const submitCommit = vm.$el.querySelector('form .btn'); + + expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); + expect(changedFileElements.length).toEqual(2); + + changedFileElements.forEach((changedFile, i) => { + expect(changedFile.textContent.trim()).toContain(vm.$store.state.changedFiles[i].path); + }); + + expect(submitCommit.disabled).toBeTruthy(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); + }); + + it('updates commitMessage in store on input', (done) => { + const textarea = vm.$el.querySelector('textarea'); + + textarea.value = 'testing commit message'; + + textarea.dispatchEvent(new Event('input')); + + getSetTimeoutPromise() + .then(() => { + expect(vm.$store.state.commit.commitMessage).toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + + describe('discard draft button', () => { + it('hidden when commitMessage is empty', () => { + expect(vm.$el.querySelector('.multi-file-commit-form .btn-default')).toBeNull(); + }); + + it('resets commitMessage when clicking discard button', (done) => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-default').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('when submitting', () => { + beforeEach(() => { + spyOn(vm, 'commitChanges'); + }); + + it('calls commitChanges', (done) => { + vm.$store.state.commit.commitMessage = 'testing commit message'; + + getSetTimeoutPromise() + .then(() => { + vm.$el.querySelector('.multi-file-commit-form .btn-success').click(); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.commitChanges).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js new file mode 100644 index 00000000000..cda88623497 --- /dev/null +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoEditor from 'ee/ide/components/repo_editor.vue'; +import monacoLoader from 'ee/ide/monaco_loader'; +import Editor from 'ee/ide/lib/editor'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoEditor', () => { + let vm; + + beforeEach((done) => { + const f = file(); + const RepoEditor = Vue.extend(repoEditor); + + vm = createComponentWithStore(RepoEditor, store, { + file: f, + }); + + f.active = true; + f.tempFile = true; + f.html = 'testing'; + vm.$store.state.openFiles.push(f); + vm.$store.state.entries[f.path] = f; + vm.monaco = true; + + vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + + Editor.editorInstance.modelManager.dispose(); + }); + + it('renders an ide container', (done) => { + Vue.nextTick(() => { + expect(vm.shouldHideEditor).toBeFalsy(); + + done(); + }); + }); + + describe('when open file is binary and not raw', () => { + beforeEach((done) => { + vm.file.binary = true; + + vm.$nextTick(done); + }); + + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + + it('shows activeFile html', () => { + expect(vm.$el.textContent).toContain('testing'); + }); + }); + + describe('createEditorInstance', () => { + it('calls createInstance when viewer is editor', (done) => { + spyOn(vm.editor, 'createInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is diff', (done) => { + vm.$store.state.viewer = 'diff'; + + spyOn(vm.editor, 'createDiffInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('setupEditor', () => { + it('creates new model', () => { + spyOn(vm.editor, 'createModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file); + expect(vm.model).not.toBeNull(); + }); + + it('attaches model to editor', () => { + spyOn(vm.editor, 'attachModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); + }); + + it('adds callback methods', () => { + spyOn(vm.editor, 'onPositionChange').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.onPositionChange).toHaveBeenCalled(); + expect(vm.model.events.size).toBe(1); + }); + + it('updates state when model content changed', (done) => { + vm.model.setValue('testing 123'); + + setTimeout(() => { + expect(vm.file.content).toBe('testing 123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js new file mode 100644 index 00000000000..13b452b1936 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import repoFileButtons from 'ee/ide/components/repo_file_buttons.vue'; +import createVueComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFileButtons', () => { + const activeFile = file(); + let vm; + + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + + return createVueComponent(RepoFileButtons, { + file: activeFile, + }); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders Raw, Blame, History, Permalink and Preview toggle', (done) => { + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.textContent.trim()).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.textContent.trim()).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.textContent.trim()).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual('Permalink'); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js new file mode 100644 index 00000000000..3bd871544ea --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoFile from 'ee/ide/components/repo_file.vue'; +import router from 'ee/ide/ide_router'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFile', () => { + let vm; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + vm = createComponentWithStore(RepoFile, store, propsData); + + vm.$mount(); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders link, icon and name', () => { + createComponent({ + file: file('t4'), + level: 0, + }); + + const name = vm.$el.querySelector('.ide-file-name'); + + expect(name.href).toMatch(''); + expect(name.textContent.trim()).toEqual(vm.file.name); + }); + + it('fires clickFile when the link is clicked', done => { + spyOn(router, 'push'); + createComponent({ + file: file('t3'), + level: 0, + }); + + vm.$el.querySelector('.file-name').click(); + + setTimeout(() => { + expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`); + + done(); + }); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + createComponent({ + file: f, + level: 0, + }); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset + .originalTitle, + ).toContain('Locked by testuser'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js new file mode 100644 index 00000000000..dd267654289 --- /dev/null +++ b/spec/javascripts/ide/components/repo_loading_file_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoLoadingFile from 'ee/ide/components/repo_loading_file.vue'; +import { resetStore } from '../helpers'; + +describe('RepoLoadingFile', () => { + let vm; + + function createComponent() { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + store, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy(); + }); + } + + function assertColumns(columns) { + columns.forEach((column) => { + const container = column.querySelector('.animation-container'); + const lines = [...container.querySelectorAll(':scope > div')]; + + expect(container).toBeTruthy(); + expect(lines.length).toEqual(6); + assertLines(lines); + }); + } + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 columns of animated LoC', () => { + vm = createComponent(); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', (done) => { + vm = createComponent(); + vm.$store.state.leftPanelCollapsed = true; + vm.$store.state.openFiles.push('test'); + + vm.$nextTick(() => { + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js new file mode 100644 index 00000000000..c3246cd1f1f --- /dev/null +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -0,0 +1,163 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import repoTab from 'ee/ide/components/repo_tab.vue'; +import router from 'ee/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(() => { + spyOn(router, 'push'); + }); + + 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('fires clickFile when the link is clicked', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'clickFile'); + + vm.$el.click(); + + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'closeFile'); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); + }); + + 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('.multi-file-modified')).toBeNull(); + + vm.$el.dispatchEvent(new Event('mouseout')); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.multi-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/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js new file mode 100644 index 00000000000..40834f230a8 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; +import repoTabs from 'ee/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, + }); + openedFiles[0].active = true; + + vm.$nextTick(() => { + const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; + + expect(tabs.length).toEqual(2); + expect(tabs[0].classList.contains('active')).toEqual(true); + expect(tabs[1].classList.contains('active')).toEqual(false); + + done(); + }); + }); + + describe('updated', () => { + it('sets showShadow as true when scroll width is larger than width', done => { + const el = document.createElement('div'); + el.innerHTML = '<div id="test-app"></div>'; + document.body.appendChild(el); + + const style = document.createElement('style'); + style.innerText = ` + .multi-file-tabs { + width: 100px; + } + + .multi-file-tabs .list-unstyled { + display: flex; + overflow-x: auto; + } + `; + document.head.appendChild(style); + + vm = createComponent( + RepoTabs, + { + files: [], + viewer: 'editor', + hasChanges: false, + }, + '#test-app', + ); + + vm + .$nextTick() + .then(() => { + expect(vm.showShadow).toEqual(false); + + vm.files = openedFiles; + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.showShadow).toEqual(true); + + style.remove(); + el.remove(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js new file mode 100644 index 00000000000..67f9eaff44a --- /dev/null +++ b/spec/javascripts/ide/helpers.js @@ -0,0 +1,21 @@ +import { decorateData } from 'ee/ide/stores/utils'; +import state from 'ee/ide/stores/state'; +import commitState from 'ee/ide/stores/modules/commit/state'; + +export const resetStore = (store) => { + const newState = { + ...state(), + commit: commitState(), + }; + store.replaceState(newState); +}; + +export const file = (name = 'name', id = name, type = '') => decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + lastCommit: {}, +}); diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js new file mode 100644 index 00000000000..677986aff91 --- /dev/null +++ b/spec/javascripts/ide/lib/common/disposable_spec.js @@ -0,0 +1,44 @@ +import Disposable from 'ee/ide/lib/common/disposable'; + +describe('Multi-file editor library disposable class', () => { + let instance; + let disposableClass; + + beforeEach(() => { + instance = new Disposable(); + + disposableClass = { + dispose: jasmine.createSpy('dispose'), + }; + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('add', () => { + it('adds disposable classes', () => { + instance.add(disposableClass); + + expect(instance.disposers.size).toBe(1); + }); + }); + + describe('dispose', () => { + beforeEach(() => { + instance.add(disposableClass); + }); + + it('calls dispose on all cached disposers', () => { + instance.dispose(); + + expect(disposableClass.dispose).toHaveBeenCalled(); + }); + + it('clears cached disposers', () => { + instance.dispose(); + + expect(instance.disposers.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..7a1fab0f74d --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -0,0 +1,123 @@ +/* global monaco */ +import eventHub from 'ee/ide/eventhub'; +import monacoLoader from 'ee/ide/monaco_loader'; +import ModelManager from 'ee/ide/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = new ModelManager(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + instance.addModel(file('path-name')); + + expect(instance.models.keys().next().value).toBe('path-name'); + }); + + it('adds model into disposable', () => { + spyOn(instance.disposable, 'add').and.callThrough(); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + spyOn(instance.models, 'get').and.callThrough(); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + + it('adds eventHub listener', () => { + const f = file(); + spyOn(eventHub, '$on').and.callThrough(); + + instance.addModel(f); + + expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + instance.addModel(file('path-name')); + + expect(instance.hasCachedModel('path-name')).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', () => { + spyOn(eventHub, '$off').and.callThrough(); + + instance.removeCachedModel(f); + + expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${f.path}`, jasmine.anything()); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js new file mode 100644 index 00000000000..dd9e4946883 --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -0,0 +1,107 @@ +/* global monaco */ +import eventHub from 'ee/ide/eventhub'; +import monacoLoader from 'ee/ide/monaco_loader'; +import Model from 'ee/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach((done) => { + spyOn(eventHub, '$on').and.callThrough(); + + monacoLoader(['vs/editor/editor.main'], () => { + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + }); + + it('adds eventHub listener', () => { + expect(eventHub.$on).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe('path'); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('setValue', () => { + it('updates models value', () => { + model.setValue('testing 123'); + + expect(model.getModel().getValue()).toBe('testing 123'); + }); + }); + + describe('onChange', () => { + it('caches event by path', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + expect(model.events.keys().next().value).toBe('path'); + }); + + it('calls callback on change', (done) => { + const spy = jasmine.createSpy(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setTimeout(() => { + expect(spy).toHaveBeenCalledWith(model, jasmine.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(model.disposable, 'dispose').and.callThrough(); + + 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', () => { + spyOn(eventHub, '$off').and.callThrough(); + + model.dispose(); + + expect(eventHub.$off).toHaveBeenCalledWith(`editor.update.model.dispose.${model.file.path}`, jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..63e4282d4df --- /dev/null +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -0,0 +1,120 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import DecorationsController from 'ee/ide/lib/decorations/controller'; +import Model from 'ee/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(monaco, file('path')); + + done(); + }); + }); + + 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('path'); + }); + + it('calls decorate method', () => { + spyOn(controller, 'decorate'); + + controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + spyOn(controller.editor.instance, 'deltaDecorations'); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []); + }); + + it('caches decorations', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('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); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js new file mode 100644 index 00000000000..90216f8b07e --- /dev/null +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -0,0 +1,176 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import ModelManager from 'ee/ide/lib/common/model_manager'; +import DecorationsController from 'ee/ide/lib/decorations/controller'; +import DirtyDiffController, { getDiffChangeType, getDecorator } from 'ee/ide/lib/diff/controller'; +import { computeDiff } from 'ee/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((done) => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(monaco); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file('path')); + + controller = new DirtyDiffController(modelManager, decorationsController); + + done(); + }); + }); + + 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).range; + + 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', () => { + spyOn(model, 'onChange'); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + spyOn(controller, 'throttledComputeDiff'); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + spyOn(controller.dirtyDiffWorker, 'postMessage'); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls decorations controller decorate', () => { + spyOn(controller.decorationsController, 'decorate'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + spyOn(controller.decorationsController, 'addDecorations'); + + controller.decorate({ data: { changes: [], path: 'path' } }); + + expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); + }); + + it('adds decorations into editor', () => { + const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations'); + + controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } }); + + expect(spy).toHaveBeenCalledWith([], [{ + range: new monaco.Range( + 1, 1, 1, 1, + ), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }]); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(controller.disposable, 'dispose').and.callThrough(); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js new file mode 100644 index 00000000000..3bdd0a77e40 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/diff_spec.js @@ -0,0 +1,80 @@ +import { computeDiff } from 'ee/ide/lib/diff/diff'; + +describe('Multi-file editor library diff calculator', () => { + describe('computeDiff', () => { + it('returns empty array if no changes', () => { + const diff = computeDiff('123', '123'); + + expect(diff).toEqual([]); + }); + + describe('modified', () => { + it('', () => { + const diff = computeDiff('123', '1234')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(2); + }); + }); + + describe('added', () => { + it('', () => { + const diff = computeDiff('123', '123\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(3); + }); + }); + + describe('removed', () => { + it('', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeTruthy(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeTruthy(); + expect(diff.lineNumber).toBe(2); + }); + }); + + it('includes line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.lineNumber).toBe(1); + }); + + it('includes end line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.endLineNumber).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js new file mode 100644 index 00000000000..b974a6befd3 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_options_spec.js @@ -0,0 +1,11 @@ +import editorOptions from 'ee/ide/lib/editor_options'; + +describe('Multi-file editor library editor options', () => { + it('returns an array', () => { + expect(editorOptions).toEqual(jasmine.any(Array)); + }); + + it('contains readOnly option', () => { + expect(editorOptions[0].readOnly).toBeDefined(); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js new file mode 100644 index 00000000000..76869bbc7ce --- /dev/null +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -0,0 +1,197 @@ +/* global monaco */ +import monacoLoader from 'ee/ide/monaco_loader'; +import editor from 'ee/ide/lib/editor'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + let el; + let holder; + + beforeEach(done => { + el = document.createElement('div'); + holder = document.createElement('div'); + el.appendChild(holder); + + document.body.appendChild(el); + + monacoLoader(['vs/editor/editor.main'], () => { + instance = editor.create(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + + el.remove(); + }); + + it('creates instance of editor', () => { + expect(editor.editorInstance).not.toBeNull(); + }); + + it('creates instance returns cached instance', () => { + expect(editor.create(monaco)).toEqual(instance); + }); + + describe('createInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'create').and.callThrough(); + + instance.createInstance(holder); + + expect(instance.monaco.editor.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', () => { + spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough(); + + instance.createDiffInstance(holder); + + expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith( + holder, + { + model: null, + contextmenu: true, + minimap: { + enabled: false, + }, + readOnly: true, + scrollBeyondLastLine: false, + }, + ); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + spyOn(instance.modelManager, 'addModel'); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); + }); + }); + + 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', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('sets original & modified when diff editor', () => { + spyOn(instance.instance, 'getEditorType').and.returnValue( + 'vs.editor.IDiffEditor', + ); + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + }); + + it('attaches the model to the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'attachModel'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith( + model, + ); + }); + + it('re-decorates with the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'reDecorate'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith( + model, + ); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + spyOn(instance.instance, 'setModel'); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + 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', () => { + spyOn(instance.modelManager, 'dispose'); + + instance.dispose(); + + expect(instance.modelManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not dispose decorationsController', () => { + spyOn(instance.decorationsController, 'dispose'); + + instance.dispose(); + + expect(instance.decorationsController.dispose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js new file mode 100644 index 00000000000..43bc256e718 --- /dev/null +++ b/spec/javascripts/ide/monaco_loader_spec.js @@ -0,0 +1,13 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; +import monacoLoader from 'ee/ide/monaco_loader'; + +describe('MonacoLoader', () => { + it('calls require.config and exports require', () => { + expect(monacoContext.require.getConfig()).toEqual(jasmine.objectContaining({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + })); + expect(monacoLoader).toBe(monacoContext.require); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js new file mode 100644 index 00000000000..55563e29d7f --- /dev/null +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -0,0 +1,421 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import eventHub from 'ee/ide/eventhub'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store file actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('closeFile', () => { + let localFile; + + beforeEach(() => { + localFile = file('testFile'); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + store.state.entries[localFile.path] = localFile; + }); + + it('closes open files', done => { + store + .dispatch('closeFile', localFile.path) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes file even if file has changes', done => { + store.state.changedFiles.push(localFile); + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('closes file & opens next available file', done => { + const f = { + ...file('newOpenFile'), + url: '/newOpenFile', + }; + + store.state.openFiles.push(f); + store.state.entries[f.path] = f; + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let localFile; + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jasmine.createSpy('scrollToTab'); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + localFile = file('setThisActive'); + + store.state.entries[localFile.path] = localFile; + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file active', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns early if file is already active', done => { + localFile.active = true; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets current active file to not active', done => { + const f = file('newActive'); + store.state.entries[f.path] = f; + localFile.active = true; + store.state.openFiles.push(localFile); + + store + .dispatch('setFileActive', f.path) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('resets location.hash for line highlighting', done => { + location.hash = 'test'; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(location.hash).not.toBe('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getFileData', () => { + let localFile; + + beforeEach(() => { + spyOn(service, 'getFileData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'testing getFileData', + }, + json: () => + Promise.resolve({ + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }), + }), + ); + + localFile = file(`newCreate-${Math.random()}`); + localFile.url = 'getFileDataURL'; + store.state.entries[localFile.path] = localFile; + }); + + it('calls the service', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file data', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }) + .catch(done.fail); + }); + + it('sets document title', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(document.title).toBe('testing getFileData'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file as active', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }) + .catch(done.fail); + }); + + it('updates file raw data', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content'); + + done(); + }) + .catch(done.fail); + }); + + it('adds file into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file once into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array if not changed', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: '', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardFileChanges', () => { + let tmpFile; + + beforeEach(() => { + spyOn(eventHub, '$on'); + + tmpFile = file(); + tmpFile.content = 'testing'; + + store.state.changedFiles.push(tmpFile); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('resets file content', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.content).not.toBe('testing'); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes temp file', done => { + tmpFile.tempFile = true; + tmpFile.opened = true; + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('does not re-open a closed temp file', done => { + tmpFile.tempFile = true; + + expect(tmpFile.opened).toBeFalsy(); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js new file mode 100644 index 00000000000..dba4bc10f9d --- /dev/null +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -0,0 +1,145 @@ +import Vue from 'vue'; +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + let projectTree; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + branchId: 'master', + }; + + beforeEach(() => { + spyOn(router, 'push'); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('getFiles', () => { + beforeEach(() => { + spyOn(service, 'getFiles').and.returnValue(Promise.resolve({ + json: () => Promise.resolve([ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]), + })); + }); + + it('calls service getFiles', (done) => { + store.dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + + done(); + }).catch(done.fail); + }); + + it('adds data into tree', (done) => { + store.dispatch('getFiles', basicCallParameters) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + + done(); + }).catch(done.fail); + }); + }); + + describe('toggleTreeOpen', () => { + let tree; + + beforeEach(() => { + tree = file('testing', '1', 'tree'); + store.state.entries[tree.path] = tree; + }); + + it('toggles the tree open', (done) => { + store.dispatch('toggleTreeOpen', tree.path).then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }).catch(done.fail); + }); + }); + + describe('getLastCommitData', () => { + beforeEach(() => { + spyOn(service, 'getTreeLastCommit').and.returnValue(Promise.resolve({ + headers: { + 'more-logs-url': null, + }, + json: () => Promise.resolve([{ + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }]), + })); + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + projectTree = store.state.trees['abcproject/mybranch']; + projectTree.tree.push(file('testing', '1', 'tree')); + projectTree.lastCommitPath = 'lastcommitpath'; + }); + + it('calls service with lastCommitPath', (done) => { + store.dispatch('getLastCommitData', projectTree) + .then(() => { + expect(service.getTreeLastCommit).toHaveBeenCalledWith('lastcommitpath'); + + done(); + }).catch(done.fail); + }); + + it('updates trees last commit data', (done) => { + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); + + done(); + }).catch(done.fail); + }); + + it('does not update entry if not found', (done) => { + projectTree.tree[0].name = 'a'; + + store.dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).not.toBe('commit message'); + + done(); + }).catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js new file mode 100644 index 00000000000..0da1226c7aa --- /dev/null +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -0,0 +1,306 @@ +import * as urlUtils from '~/lib/utils/url_utility'; +import store from 'ee/ide/stores'; +import router from 'ee/ide/ide_router'; +import { resetStore, file } from '../helpers'; + +describe('Multi-file store actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', done => { + spyOn(urlUtils, 'visitUrl'); + + store + .dispatch('redirectToUrl', 'test') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', done => { + store + .dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + beforeEach(() => { + const f = file('discardAll'); + f.changed = true; + + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); + + it('discards changes in file', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.openFiles.changed).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all files from changedFiles state', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('closeAllFiles', () => { + beforeEach(() => { + const f = file('closeAll'); + store.state.openFiles.push(f); + store.state.openFiles[0].opened = true; + store.state.entries[f.path] = f; + }); + + it('closes all open files', done => { + store + .dispatch('closeAllFiles') + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('createTempEntry', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'mybranch'; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('tree', () => { + it('creates temp tree', done => { + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'test', + type: 'tree', + }) + .then(() => { + const entry = store.state.entries.test; + + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates new folder inside another tree', done => { + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing/test', + type: 'tree', + }) + .then(() => { + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('does not create new tree if already exists', done => { + const tree = { + type: 'tree', + path: 'testing', + tempFile: false, + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing', + type: 'tree', + }) + .then(() => { + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('blob', () => { + it('creates temp file', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.tempFile).toBeTruthy(); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe( + 1, + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to open files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to changed files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.changedFiles.length).toBe(1); + expect(store.state.changedFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('sets tmp file as active', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('creates flash message if file already exists', done => { + const f = file('test', '1', 'blob'); + store.state.trees['abcproject/mybranch'].tree = [f]; + store.state.entries[f.path] = f; + + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('popHistoryState', () => {}); + + describe('scrollToTab', () => { + it('focuses the current active element', done => { + document.body.innerHTML += + '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>'; + const el = document.querySelector('.repo-tab'); + spyOn(el, 'focus'); + + store + .dispatch('scrollToTab') + .then(() => { + setTimeout(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); + + describe('updateViewer', () => { + it('updates viewer state', done => { + store + .dispatch('updateViewer', 'diff') + .then(() => { + expect(store.state.viewer).toBe('diff'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js new file mode 100644 index 00000000000..2fb69339915 --- /dev/null +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -0,0 +1,55 @@ +import * as getters from 'ee/ide/stores/getters'; +import state from 'ee/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('activeFile', () => { + it('returns the current active file', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + localState.openFiles[1].active = true; + + expect(getters.activeFile(localState).name).toBe('active'); + }); + + it('returns undefined if no active files are found', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + + expect(getters.activeFile(localState)).toBeNull(); + }); + }); + + describe('modifiedFiles', () => { + it('returns a list of modified files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('changed')); + localState.changedFiles[0].changed = true; + + const modifiedFiles = getters.modifiedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('changed'); + }); + }); + + describe('addedFiles', () => { + it('returns a list of added files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('added')); + localState.changedFiles[0].changed = true; + localState.changedFiles[0].tempFile = true; + + const modifiedFiles = getters.addedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('added'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js new file mode 100644 index 00000000000..0aef29f77e3 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -0,0 +1,450 @@ +import store from 'ee/ide/stores'; +import service from 'ee/ide/services'; +import router from 'ee/ide/ide_router'; +import * as urlUtils from '~/lib/utils/url_utility'; +import eventHub from 'ee/ide/eventhub'; +import * as consts from 'ee/ide/stores/modules/commit/constants'; +import { resetStore, file } from 'spec/ide/helpers'; + +describe('IDE commit module actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('updateCommitMessage', () => { + it('updates store with new commit message', (done) => { + store.dispatch('commit/updateCommitMessage', 'testing') + .then(() => { + expect(store.state.commit.commitMessage).toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardDraft', () => { + it('resets commit message to blank', (done) => { + store.state.commit.commitMessage = 'testing'; + + store.dispatch('commit/discardDraft') + .then(() => { + expect(store.state.commit.commitMessage).not.toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCommitAction', () => { + it('updates store with new commit action', (done) => { + store.dispatch('commit/updateCommitAction', '1') + .then(() => { + expect(store.state.commit.commitAction).toBe('1'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateBranchName', () => { + it('updates store with new branch name', (done) => { + store.dispatch('commit/updateBranchName', 'branch-name') + .then(() => { + expect(store.state.commit.newBranchName).toBe('branch-name'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setLastCommitMessage', () => { + beforeEach(() => { + Object.assign(store.state, { + currentProjectId: 'abcproject', + projects: { + abcproject: { + web_url: 'http://testing', + }, + }, + }); + }); + + it('updates commit message with short_id', (done) => { + store.dispatch('commit/setLastCommitMessage', { short_id: '123' }) + .then(() => { + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('updates commit message with stats', (done) => { + store.dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) + .then(() => { + expect(store.state.lastCommitMsg).toBe('Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('checkCommitStatus', () => { + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + it('calls service', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '123' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith('abcproject', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('returns true if current ref does not equal returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '123' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then((val) => { + expect(val).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns false if current ref equals returned ID', (done) => { + spyOn(service, 'getBranchData').and.returnValue(Promise.resolve({ + data: { + commit: { id: '1' }, + }, + })); + + store.dispatch('commit/checkCommitStatus') + .then((val) => { + expect(val).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('updateFilesAfterCommit', () => { + const data = { + id: '123', + message: 'testing commit message', + committed_date: '123', + committer_name: 'root', + }; + const branch = 'master'; + let f; + + beforeEach(() => { + spyOn(eventHub, '$emit'); + + f = file('changedFile'); + Object.assign(f, { + active: true, + changed: true, + content: 'file content', + }); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + }, + }, + }; + store.state.changedFiles.push(f, { + ...file('changedFile2'), + changed: true, + }); + store.state.openFiles = store.state.changedFiles; + + store.state.changedFiles.forEach((changedFile) => { + store.state.entries[changedFile.path] = changedFile; + }); + }); + + it('updates stores working reference', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect( + store.state.projects.abcproject.branches.master.workingReference, + ).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('resets all files changed status', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + store.state.openFiles.forEach((entry) => { + expect(entry.changed).toBeFalsy(); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all changed files', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('sets files commit data', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.lastCommit.message).toBe(data.message); + }) + .then(done) + .catch(done.fail); + }); + + it('updates raw content for changed file', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.raw).toBe(f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('emits changed event for file', (done) => { + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.path}`, f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('pushes route to new branch if commitAction is new branch', (done) => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/abcproject/blob/master/${f.path}`, + ); + }) + .then(done) + .catch(done.fail); + }); + + it('resets stores commit actions', (done) => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store.dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + beforeEach(() => { + spyOn(urlUtils, 'visitUrl'); + + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + }, + }, + }; + store.state.changedFiles.push(file('changed')); + store.state.changedFiles[0].active = true; + store.state.openFiles = store.state.changedFiles; + + store.state.openFiles.forEach((f) => { + store.state.entries[f.path] = f; + }); + + store.state.commit.commitAction = '2'; + store.state.commit.commitMessage = 'testing 123'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + data: { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, + }, + })); + }); + + it('calls service', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: jasmine.anything(), + commit_message: 'testing 123', + actions: [{ + action: 'update', + file_path: jasmine.anything(), + content: jasmine.anything(), + encoding: jasmine.anything(), + }], + start_branch: 'master', + }); + + done(); + }).catch(done.fail); + }); + + it('pushes router to new route', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(router.push).toHaveBeenCalledWith( + `/project/${store.state.currentProjectId}/blob/${store.getters['commit/newBranchName']}/changed`, + ); + + done(); + }).catch(done.fail); + }); + + it('sets last Commit Msg', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + + done(); + }).catch(done.fail); + }); + + it('adds commit data to changed files', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + expect(store.state.openFiles[0].lastCommit.message).toBe('test message'); + + done(); + }).catch(done.fail); + }); + + it('redirects to new merge request page', (done) => { + spyOn(eventHub, '$on'); + + store.state.commit.commitAction = '3'; + + store.dispatch('commit/commitChanges') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + `webUrl/merge_requests/new?merge_request[source_branch]=${store.getters['commit/newBranchName']}&merge_request[target_branch]=master`, + ); + + done(); + }).catch(done.fail); + }); + }); + + describe('failed', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue(Promise.resolve({ + data: { + message: 'failed message', + }, + })); + }); + + it('shows failed message', (done) => { + store.dispatch('commit/commitChanges') + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe( + 'failed message', + ); + + done(); + }).catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js new file mode 100644 index 00000000000..b1467bcf3c7 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -0,0 +1,114 @@ +import commitState from 'ee/ide/stores/modules/commit/state'; +import * as consts from 'ee/ide/stores/modules/commit/constants'; +import * as getters from 'ee/ide/stores/modules/commit/getters'; + +describe('IDE commit module getters', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('discardDraftButtonDisabled', () => { + it('returns true when commitMessage is empty', () => { + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + + it('returns false when commitMessage is not empty & loading is false', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = false; + + expect(getters.discardDraftButtonDisabled(state)).toBeFalsy(); + }); + + it('returns true when commitMessage is not empty & loading is true', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = true; + + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + }); + + describe('commitButtonDisabled', () => { + const localGetters = { + discardDraftButtonDisabled: false, + }; + const rootState = { + changedFiles: ['a'], + }; + + it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeFalsy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { + rootState.changedFiles.length = 0; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is true', () => { + localGetters.discardDraftButtonDisabled = true; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + localGetters.discardDraftButtonDisabled = false; + rootState.changedFiles.length = 0; + + expect(getters.commitButtonDisabled(state, localGetters, rootState)).toBeTruthy(); + }); + }); + + describe('newBranchName', () => { + it('includes username, currentBranchId, patch & random number', () => { + gon.current_username = 'username'; + + const branch = getters.newBranchName(state, null, { currentBranchId: 'testing' }); + + expect(branch).toMatch(/username-testing-patch-\d{5}$/); + }); + }); + + describe('branchName', () => { + const rootState = { + currentBranchId: 'master', + }; + const localGetters = { + newBranchName: 'newBranchName', + }; + + beforeEach(() => { + Object.assign(state, { + newBranchName: 'state-newBranchName', + }); + }); + + it('defualts to currentBranchId', () => { + expect(getters.branchName(state, null, rootState)).toBe('master'); + }); + + ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + Object.assign(state, { + commitAction: consts[type], + }); + }); + + it('uses newBranchName when not empty', () => { + expect(getters.branchName(state, localGetters, rootState)).toBe('state-newBranchName'); + }); + + it('uses getters newBranchName when state newBranchName is empty', () => { + Object.assign(state, { + newBranchName: '', + }); + + expect(getters.branchName(state, localGetters, rootState)).toBe('newBranchName'); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js new file mode 100644 index 00000000000..fa43e3d9d02 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js @@ -0,0 +1,42 @@ +import commitState from 'ee/ide/stores/modules/commit/state'; +import mutations from 'ee/ide/stores/modules/commit/mutations'; + +describe('IDE commit module mutations', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('UPDATE_COMMIT_MESSAGE', () => { + it('updates commitMessage', () => { + mutations.UPDATE_COMMIT_MESSAGE(state, 'testing'); + + expect(state.commitMessage).toBe('testing'); + }); + }); + + describe('UPDATE_COMMIT_ACTION', () => { + it('updates commitAction', () => { + mutations.UPDATE_COMMIT_ACTION(state, 'testing'); + + expect(state.commitAction).toBe('testing'); + }); + }); + + describe('UPDATE_NEW_BRANCH_NAME', () => { + it('updates newBranchName', () => { + mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing'); + + expect(state.newBranchName).toBe('testing'); + }); + }); + + describe('UPDATE_LOADING', () => { + it('updates submitCommitLoading', () => { + mutations.UPDATE_LOADING(state, true); + + expect(state.submitCommitLoading).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js new file mode 100644 index 00000000000..1601769144a --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -0,0 +1,18 @@ +import mutations from 'ee/ide/stores/mutations/branch'; +import state from 'ee/ide/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('SET_CURRENT_BRANCH', () => { + it('sets currentBranch', () => { + mutations.SET_CURRENT_BRANCH(localState, 'master'); + + expect(localState.currentBranchId).toBe('master'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js new file mode 100644 index 00000000000..944639c3336 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -0,0 +1,157 @@ +import mutations from 'ee/ide/stores/mutations/file'; +import state from 'ee/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store file mutations', () => { + let localState; + let localFile; + + beforeEach(() => { + localState = state(); + localFile = file(); + + localState.entries[localFile.path] = localFile; + }); + + describe('SET_FILE_ACTIVE', () => { + it('sets the file active', () => { + mutations.SET_FILE_ACTIVE(localState, { + path: localFile.path, + active: true, + }); + + expect(localFile.active).toBeTruthy(); + }); + }); + + describe('TOGGLE_FILE_OPEN', () => { + beforeEach(() => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + }); + + it('adds into opened files', () => { + expect(localFile.opened).toBeTruthy(); + expect(localState.openFiles.length).toBe(1); + }); + + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + describe('SET_FILE_DATA', () => { + it('sets extra file data', () => { + mutations.SET_FILE_DATA(localState, { + data: { + blame_path: 'blame', + commits_path: 'commits', + permalink: 'permalink', + raw_path: 'raw', + binary: true, + render_error: 'render_error', + }, + file: localFile, + }); + + expect(localFile.blamePath).toBe('blame'); + expect(localFile.commitsPath).toBe('commits'); + expect(localFile.permalink).toBe('permalink'); + expect(localFile.rawPath).toBe('raw'); + expect(localFile.binary).toBeTruthy(); + expect(localFile.renderError).toBe('render_error'); + }); + }); + + describe('SET_FILE_RAW_DATA', () => { + it('sets raw data', () => { + mutations.SET_FILE_RAW_DATA(localState, { + file: localFile, + raw: 'testing', + }); + + expect(localFile.raw).toBe('testing'); + }); + }); + + describe('UPDATE_FILE_CONTENT', () => { + beforeEach(() => { + localFile.raw = 'test'; + }); + + it('sets content', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'test', + }); + + expect(localFile.content).toBe('test'); + }); + + it('sets changed if content does not match raw', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'testing', + }); + + expect(localFile.content).toBe('testing'); + expect(localFile.changed).toBeTruthy(); + }); + + it('sets changed if file is a temp file', () => { + localFile.tempFile = true; + + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: '', + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); + + describe('DISCARD_FILE_CHANGES', () => { + beforeEach(() => { + localFile.content = 'test'; + localFile.changed = true; + }); + + it('resets content and changed', () => { + mutations.DISCARD_FILE_CHANGES(localState, localFile.path); + + expect(localFile.content).toBe(''); + expect(localFile.changed).toBeFalsy(); + }); + }); + + describe('ADD_FILE_TO_CHANGED', () => { + it('adds file into changed files array', () => { + mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(1); + }); + }); + + describe('REMOVE_FILE_FROM_CHANGED', () => { + it('removes files from changed files array', () => { + localState.changedFiles.push(localFile); + + mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(0); + }); + }); + + describe('TOGGLE_FILE_CHANGED', () => { + it('updates file changed status', () => { + mutations.TOGGLE_FILE_CHANGED(localState, { + file: localFile, + changed: true, + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js new file mode 100644 index 00000000000..e321eff8749 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/tree_spec.js @@ -0,0 +1,67 @@ +import mutations from 'ee/ide/stores/mutations/tree'; +import state from 'ee/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store tree mutations', () => { + let localState; + let localTree; + + beforeEach(() => { + localState = state(); + localTree = file(); + + localState.entries[localTree.path] = localTree; + }); + + describe('TOGGLE_TREE_OPEN', () => { + it('toggles tree open', () => { + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeTruthy(); + + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeFalsy(); + }); + }); + + describe('SET_DIRECTORY_DATA', () => { + const data = [{ + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }]; + + it('adds directory data', () => { + localState.trees['project/master'] = { + tree: [], + }; + + mutations.SET_DIRECTORY_DATA(localState, { + data, + treePath: 'project/master', + }); + + const tree = localState.trees['project/master']; + + expect(tree.tree.length).toBe(3); + expect(tree.tree[0].name).toBe('tree'); + expect(tree.tree[1].name).toBe('submodule'); + expect(tree.tree[2].name).toBe('blob'); + }); + }); + + describe('REMOVE_ALL_CHANGES_FILES', () => { + it('removes all files from changedFiles state', () => { + localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES')); + + mutations.REMOVE_ALL_CHANGES_FILES(localState); + + expect(localState.changedFiles.length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js new file mode 100644 index 00000000000..e0d214010d5 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -0,0 +1,79 @@ +import mutations from 'ee/ide/stores/mutations'; +import state from 'ee/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store mutations', () => { + let localState; + let entry; + + beforeEach(() => { + localState = state(); + entry = file(); + + localState.entries[entry.path] = entry; + }); + + describe('SET_INITIAL_DATA', () => { + it('sets all initial data', () => { + mutations.SET_INITIAL_DATA(localState, { + test: 'test', + }); + + expect(localState.test).toBe('test'); + }); + }); + + describe('TOGGLE_LOADING', () => { + it('toggles loading of entry', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeFalsy(); + }); + + it('toggles loading of entry and sets specific value', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry, forceValue: true }); + + expect(entry.loading).toBeTruthy(); + }); + }); + + 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('UPDATE_VIEWER', () => { + it('sets viewer state', () => { + mutations.UPDATE_VIEWER(localState, 'diff'); + + expect(localState.viewer).toBe('diff'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js new file mode 100644 index 00000000000..a473d3a4294 --- /dev/null +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -0,0 +1,60 @@ +import * as utils from 'ee/ide/stores/utils'; + +describe('Multi-file store utils', () => { + describe('setPageTitle', () => { + it('sets the document page title', () => { + utils.setPageTitle('test'); + + expect(document.title).toBe('test'); + }); + }); + + describe('findIndexOfFile', () => { + let localState; + + beforeEach(() => { + localState = [{ + path: '1', + }, { + path: '2', + }]; + }); + + it('finds in the index of an entry by path', () => { + const index = utils.findIndexOfFile(localState, { + path: '2', + }); + + expect(index).toBe(1); + }); + }); + + describe('findEntry', () => { + let localState; + + beforeEach(() => { + localState = { + tree: [{ + type: 'tree', + name: 'test', + }, { + type: 'blob', + name: 'file', + }], + }; + }); + + it('returns an entry found by name', () => { + const foundEntry = utils.findEntry(localState.tree, 'tree', 'test'); + + expect(foundEntry.type).toBe('tree'); + expect(foundEntry.name).toBe('test'); + }); + + it('returns undefined when no entry found', () => { + const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); + + expect(foundEntry).toBeUndefined(); + }); + }); +}); |