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