diff options
Diffstat (limited to 'spec/frontend/ide')
68 files changed, 7914 insertions, 482 deletions
diff --git a/spec/frontend/ide/commit_icon_spec.js b/spec/frontend/ide/commit_icon_spec.js new file mode 100644 index 00000000000..90b8e34497c --- /dev/null +++ b/spec/frontend/ide/commit_icon_spec.js @@ -0,0 +1,45 @@ +import { commitItemIconMap } from '~/ide/constants'; +import { decorateData } from '~/ide/stores/utils'; +import getCommitIconMap from '~/ide/commit_icon'; + +const createFile = (name = 'name', id = name, type = '', parent = null) => + decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: parent ? `${parent.path}/${name}` : name, + parentPath: parent ? parent.path : '', + lastCommit: {}, + }); + +describe('getCommitIconMap', () => { + let entry; + + beforeEach(() => { + entry = createFile('Entry item'); + }); + + it('renders "deleted" icon for deleted entries', () => { + entry.deleted = true; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.deleted); + }); + + it('renders "addition" icon for temp entries', () => { + entry.tempFile = true; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.addition); + }); + + it('renders "modified" icon for newly-renamed entries', () => { + entry.prevPath = 'foo/bar'; + entry.tempFile = false; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); + }); + + it('renders "modified" icon even for temp entries if they are newly-renamed', () => { + entry.prevPath = 'foo/bar'; + entry.tempFile = true; + expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); + }); +}); diff --git a/spec/frontend/ide/components/branches/item_spec.js b/spec/frontend/ide/components/branches/item_spec.js index 138443b715e..d8175025755 100644 --- a/spec/frontend/ide/components/branches/item_spec.js +++ b/spec/frontend/ide/components/branches/item_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import router from '~/ide/ide_router'; +import { createStore } from '~/ide/stores'; +import { createRouter } from '~/ide/ide_router'; import Item from '~/ide/components/branches/item.vue'; import Icon from '~/vue_shared/components/icon.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -13,6 +14,8 @@ const TEST_PROJECT_ID = projectData.name_with_namespace; describe('IDE branch item', () => { let wrapper; + let store; + let router; function createComponent(props = {}) { wrapper = shallowMount(Item, { @@ -22,9 +25,15 @@ describe('IDE branch item', () => { isActive: false, ...props, }, + router, }); } + beforeEach(() => { + store = createStore(); + router = createRouter(store); + }); + afterEach(() => { wrapper.destroy(); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 129180bb46e..c62df4a3795 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -5,11 +5,14 @@ import store from '~/ide/stores'; import CommitForm from '~/ide/components/commit_sidebar/form.vue'; import { leftSidebarViews } from '~/ide/constants'; import { resetStore } from '../../helpers'; +import waitForPromises from 'helpers/wait_for_promises'; describe('IDE commit form', () => { const Component = Vue.extend(CommitForm); let vm; + const beginCommitButton = () => vm.$el.querySelector('[data-testid="begin-commit-button"]'); + beforeEach(() => { store.state.changedFiles.push('test'); store.state.currentProjectId = 'abcproject'; @@ -25,8 +28,15 @@ describe('IDE commit form', () => { resetStore(vm.$store); }); - it('enables button when has changes', () => { - expect(vm.$el.querySelector('[disabled]')).toBe(null); + it('enables begin commit button when there are changes', () => { + expect(beginCommitButton()).not.toHaveAttr('disabled'); + }); + + it('disables begin commit button when there are no changes', async () => { + store.state.changedFiles = []; + await vm.$nextTick(); + + expect(beginCommitButton()).toHaveAttr('disabled'); }); describe('compact', () => { @@ -37,8 +47,8 @@ describe('IDE commit form', () => { }); it('renders commit button in compact mode', () => { - expect(vm.$el.querySelector('.btn-primary')).not.toBeNull(); - expect(vm.$el.querySelector('.btn-primary').textContent).toContain('Commit'); + expect(beginCommitButton()).not.toBeNull(); + expect(beginCommitButton().textContent).toContain('Commit'); }); it('does not render form', () => { @@ -54,7 +64,7 @@ describe('IDE commit form', () => { }); it('shows form when clicking commit button', () => { - vm.$el.querySelector('.btn-primary').click(); + beginCommitButton().click(); return vm.$nextTick(() => { expect(vm.$el.querySelector('form')).not.toBeNull(); @@ -62,31 +72,117 @@ describe('IDE commit form', () => { }); it('toggles activity bar view when clicking commit button', () => { - vm.$el.querySelector('.btn-primary').click(); + beginCommitButton().click(); return vm.$nextTick(() => { expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name); }); }); - it('collapses if lastCommitMsg is set to empty and current view is not commit view', () => { + it('collapses if lastCommitMsg is set to empty and current view is not commit view', async () => { store.state.lastCommitMsg = 'abc'; store.state.currentActivityView = leftSidebarViews.edit.name; + await vm.$nextTick(); - return vm - .$nextTick() - .then(() => { - // if commit message is set, form is uncollapsed - expect(vm.isCompact).toBe(false); + // if commit message is set, form is uncollapsed + expect(vm.isCompact).toBe(false); - store.state.lastCommitMsg = ''; + store.state.lastCommitMsg = ''; + await vm.$nextTick(); - return vm.$nextTick(); - }) - .then(() => { - // collapsed when set to empty - expect(vm.isCompact).toBe(true); - }); + // collapsed when set to empty + expect(vm.isCompact).toBe(true); + }); + + it('collapses if in commit view but there are no changes and vice versa', async () => { + store.state.currentActivityView = leftSidebarViews.commit.name; + await vm.$nextTick(); + + // expanded by default if there are changes + expect(vm.isCompact).toBe(false); + + store.state.changedFiles = []; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + + store.state.changedFiles.push('test'); + await vm.$nextTick(); + + // uncollapsed once again + expect(vm.isCompact).toBe(false); + }); + + it('collapses if switched from commit view to edit view and vice versa', async () => { + store.state.currentActivityView = leftSidebarViews.edit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + + store.state.currentActivityView = leftSidebarViews.commit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(false); + + store.state.currentActivityView = leftSidebarViews.edit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + }); + + describe('when window height is less than MAX_WINDOW_HEIGHT', () => { + let oldHeight; + + beforeEach(() => { + oldHeight = window.innerHeight; + window.innerHeight = 700; + }); + + afterEach(() => { + window.innerHeight = oldHeight; + }); + + it('stays collapsed when switching from edit view to commit view and back', async () => { + store.state.currentActivityView = leftSidebarViews.edit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + + store.state.currentActivityView = leftSidebarViews.commit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + + store.state.currentActivityView = leftSidebarViews.edit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + }); + + it('stays uncollapsed if changes are added or removed', async () => { + store.state.currentActivityView = leftSidebarViews.commit.name; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + + store.state.changedFiles = []; + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + + store.state.changedFiles.push('test'); + await vm.$nextTick(); + + expect(vm.isCompact).toBe(true); + }); + + it('uncollapses when clicked on Commit button in the edit view', async () => { + store.state.currentActivityView = leftSidebarViews.edit.name; + beginCommitButton().click(); + await waitForPromises(); + + expect(vm.isCompact).toBe(false); + }); }); }); @@ -118,7 +214,7 @@ describe('IDE commit form', () => { }); it('always opens itself in full view current activity view is not commit view when clicking commit button', () => { - vm.$el.querySelector('.btn-primary').click(); + beginCommitButton().click(); return vm.$nextTick(() => { expect(store.state.currentActivityView).toBe(leftSidebarViews.commit.name); diff --git a/spec/frontend/ide/components/commit_sidebar/list_item_spec.js b/spec/frontend/ide/components/commit_sidebar/list_item_spec.js index ebb41448905..7ce628d4da7 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_item_spec.js @@ -1,17 +1,22 @@ import Vue from 'vue'; import { trimText } from 'helpers/text_helper'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import listItem from '~/ide/components/commit_sidebar/list_item.vue'; -import router from '~/ide/ide_router'; -import { file, resetStore } from '../../helpers'; +import { createRouter } from '~/ide/ide_router'; +import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list item', () => { let vm; let f; let findPathEl; + let store; + let router; beforeEach(() => { + store = createStore(); + router = createRouter(store); + const Component = Vue.extend(listItem); f = file('test-file'); @@ -28,8 +33,6 @@ describe('Multi-file editor commit sidebar list item', () => { afterEach(() => { vm.$destroy(); - - resetStore(store); }); const findPathText = () => trimText(findPathEl.textContent); diff --git a/spec/frontend/ide/components/commit_sidebar/message_field_spec.js b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js new file mode 100644 index 00000000000..d6ea8b9a4bd --- /dev/null +++ b/spec/frontend/ide/components/commit_sidebar/message_field_spec.js @@ -0,0 +1,170 @@ +import Vue from 'vue'; +import createComponent from 'helpers/vue_mount_component_helper'; +import CommitMessageField from '~/ide/components/commit_sidebar/message_field.vue'; + +describe('IDE commit message field', () => { + const Component = Vue.extend(CommitMessageField); + let vm; + + beforeEach(() => { + setFixtures('<div id="app"></div>'); + + vm = createComponent( + Component, + { + text: '', + placeholder: 'testing', + }, + '#app', + ); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('adds is-focused class on focus', done => { + vm.$el.querySelector('textarea').focus(); + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.is-focused')).not.toBeNull(); + + done(); + }); + }); + + it('removed is-focused class on blur', done => { + vm.$el.querySelector('textarea').focus(); + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.is-focused')).not.toBeNull(); + + vm.$el.querySelector('textarea').blur(); + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.$el.querySelector('.is-focused')).toBeNull(); + + done(); + }) + .then(done) + .catch(done.fail); + }); + + it('emits input event on input', () => { + jest.spyOn(vm, '$emit').mockImplementation(); + + const textarea = vm.$el.querySelector('textarea'); + textarea.value = 'testing'; + + textarea.dispatchEvent(new Event('input')); + + expect(vm.$emit).toHaveBeenCalledWith('input', 'testing'); + }); + + describe('highlights', () => { + describe('subject line', () => { + it('does not highlight less than 50 characters', done => { + vm.text = 'text less than 50 chars'; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.highlights span').textContent).toContain( + 'text less than 50 chars', + ); + + expect(vm.$el.querySelector('mark').style.display).toBe('none'); + }) + .then(done) + .catch(done.fail); + }); + + it('highlights characters over 50 length', done => { + vm.text = + 'text less than 50 chars that should not highlighted. text more than 50 should be highlighted'; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelector('.highlights span').textContent).toContain( + 'text less than 50 chars that should not highlighte', + ); + + expect(vm.$el.querySelector('mark').style.display).not.toBe('none'); + expect(vm.$el.querySelector('mark').textContent).toBe( + 'd. text more than 50 should be highlighted', + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('body text', () => { + it('does not highlight body text less tan 72 characters', done => { + vm.text = 'subject line\nbody content'; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelectorAll('.highlights span').length).toBe(2); + expect(vm.$el.querySelectorAll('mark')[1].style.display).toBe('none'); + }) + .then(done) + .catch(done.fail); + }); + + it('highlights body text more than 72 characters', done => { + vm.text = + 'subject line\nbody content that will be highlighted when it is more than 72 characters in length'; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelectorAll('.highlights span').length).toBe(2); + expect(vm.$el.querySelectorAll('mark')[1].style.display).not.toBe('none'); + expect(vm.$el.querySelectorAll('mark')[1].textContent).toBe(' in length'); + }) + .then(done) + .catch(done.fail); + }); + + it('highlights body text & subject line', done => { + vm.text = + 'text less than 50 chars that should not highlighted\nbody content that will be highlighted when it is more than 72 characters in length'; + + vm.$nextTick() + .then(() => { + expect(vm.$el.querySelectorAll('.highlights span').length).toBe(2); + expect(vm.$el.querySelectorAll('mark').length).toBe(2); + + expect(vm.$el.querySelectorAll('mark')[0].textContent).toContain('d'); + expect(vm.$el.querySelectorAll('mark')[1].textContent).toBe(' in length'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('scrolling textarea', () => { + it('updates transform of highlights', done => { + vm.text = 'subject line\n\n\n\n\n\n\n\n\n\n\nbody content'; + + vm.$nextTick() + .then(() => { + vm.$el.querySelector('textarea').scrollTo(0, 50); + + vm.handleScroll(); + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.scrollTop).toBe(50); + expect(vm.$el.querySelector('.highlights').style.transform).toBe( + 'translate3d(0, -50px, 0)', + ); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/ide/components/ide_sidebar_nav_spec.js b/spec/frontend/ide/components/ide_sidebar_nav_spec.js new file mode 100644 index 00000000000..49d476b56e4 --- /dev/null +++ b/spec/frontend/ide/components/ide_sidebar_nav_spec.js @@ -0,0 +1,118 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue'; +import { SIDE_RIGHT, SIDE_LEFT } from '~/ide/constants'; + +const TEST_TABS = [ + { + title: 'Lorem', + icon: 'angle-up', + views: [{ name: 'lorem-1' }, { name: 'lorem-2' }], + }, + { + title: 'Ipsum', + icon: 'angle-down', + views: [{ name: 'ipsum-1' }, { name: 'ipsum-2' }], + }, +]; +const TEST_CURRENT_INDEX = 1; +const TEST_CURRENT_VIEW = TEST_TABS[TEST_CURRENT_INDEX].views[1].name; +const TEST_OPEN_VIEW = TEST_TABS[TEST_CURRENT_INDEX].views[0]; + +describe('ide/components/ide_sidebar_nav', () => { + let wrapper; + + const createComponent = (props = {}) => { + if (wrapper) { + throw new Error('wrapper already exists'); + } + + wrapper = shallowMount(IdeSidebarNav, { + propsData: { + tabs: TEST_TABS, + currentView: TEST_CURRENT_VIEW, + isOpen: false, + ...props, + }, + directives: { + tooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findButtons = () => wrapper.findAll('li button'); + const findButtonsData = () => + findButtons().wrappers.map(button => { + return { + title: button.attributes('title'), + ariaLabel: button.attributes('aria-label'), + classes: button.classes(), + qaSelector: button.attributes('data-qa-selector'), + icon: button.find(GlIcon).props('name'), + tooltip: getBinding(button.element, 'tooltip').value, + }; + }); + const clickTab = () => + findButtons() + .at(TEST_CURRENT_INDEX) + .trigger('click'); + + describe.each` + isOpen | side | otherSide | classes | classesObj | emitEvent | emitArg + ${false} | ${SIDE_LEFT} | ${SIDE_RIGHT} | ${[]} | ${{}} | ${'open'} | ${[TEST_OPEN_VIEW]} + ${false} | ${SIDE_RIGHT} | ${SIDE_LEFT} | ${['is-right']} | ${{}} | ${'open'} | ${[TEST_OPEN_VIEW]} + ${true} | ${SIDE_RIGHT} | ${SIDE_LEFT} | ${['is-right']} | ${{ [TEST_CURRENT_INDEX]: ['active'] }} | ${'close'} | ${[]} + `( + 'with side = $side, isOpen = $isOpen', + ({ isOpen, side, otherSide, classes, classesObj, emitEvent, emitArg }) => { + let bsTooltipHide; + + beforeEach(() => { + createComponent({ isOpen, side }); + + bsTooltipHide = jest.fn(); + wrapper.vm.$root.$on('bv::hide::tooltip', bsTooltipHide); + }); + + it('renders buttons', () => { + expect(findButtonsData()).toEqual( + TEST_TABS.map((tab, index) => ({ + title: tab.title, + ariaLabel: tab.title, + classes: ['ide-sidebar-link', ...classes, ...(classesObj[index] || [])], + qaSelector: `${tab.title.toLowerCase()}_tab_button`, + icon: tab.icon, + tooltip: { + container: 'body', + placement: otherSide, + }, + })), + ); + }); + + it('when tab clicked, emits event', () => { + expect(wrapper.emitted()).toEqual({}); + + clickTab(); + + expect(wrapper.emitted()).toEqual({ + [emitEvent]: [emitArg], + }); + }); + + it('when tab clicked, hides tooltip', () => { + expect(bsTooltipHide).not.toHaveBeenCalled(); + + clickTab(); + + expect(bsTooltipHide).toHaveBeenCalled(); + }); + }, + ); +}); diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index 78a280e6304..efc1d984dec 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -1,11 +1,18 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import ide from '~/ide/components/ide.vue'; import { file, resetStore } from '../helpers'; import { projectData } from '../mock_data'; +import extendStore from '~/ide/stores/extend'; + +let store; function bootstrap(projData) { + store = createStore(); + + extendStore(store, document.createElement('div')); + const Component = Vue.extend(ide); store.state.currentProjectId = 'abcproject'; diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index 99c27ca30fb..847464ed806 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -1,13 +1,14 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import IdeStatusList from '~/ide/components/ide_status_list.vue'; +import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync_status_safe.vue'; const TEST_FILE = { name: 'lorem.md', - eol: 'LF', editorRow: 3, editorColumn: 23, fileLanguage: 'markdown', + content: 'abc\nndef', }; const localVue = createLocalVue(); @@ -55,7 +56,8 @@ describe('ide/components/ide_status_list', () => { }); it('shows file eol', () => { - expect(wrapper.text()).toContain(TEST_FILE.name); + expect(wrapper.text()).not.toContain('CRLF'); + expect(wrapper.text()).toContain('LF'); }); it('shows file editor position', () => { @@ -78,13 +80,9 @@ describe('ide/components/ide_status_list', () => { }); }); - it('adds slot as child of list', () => { - createComponent({ - slots: { - default: ['<div class="js-test">Hello</div>', '<div class="js-test">World</div>'], - }, - }); + it('renders terminal sync status', () => { + createComponent(); - expect(wrapper.find('.ide-status-list').findAll('.js-test').length).toEqual(2); + expect(wrapper.find(TerminalSyncStatusSafe).exists()).toBe(true); }); }); diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index db5175c3f7b..bdd3d439fd4 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -14,7 +14,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` /> <strong - class="prepend-left-8 text-truncate" + class="gl-ml-3 text-truncate" data-container="body" data-original-title="" title="" @@ -25,7 +25,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` </strong> <div - class="append-right-8 prepend-left-4" + class="gl-mr-3 gl-ml-2" > <span class="badge badge-pill" diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js new file mode 100644 index 00000000000..8f3815d5aab --- /dev/null +++ b/spec/frontend/ide/components/jobs/detail_spec.js @@ -0,0 +1,187 @@ +import Vue from 'vue'; +import JobDetail from '~/ide/components/jobs/detail.vue'; +import { createStore } from '~/ide/stores'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { jobs } from '../../mock_data'; +import { TEST_HOST } from 'helpers/test_constants'; + +describe('IDE jobs detail view', () => { + let vm; + + const createComponent = () => { + const store = createStore(); + + store.state.pipelines.detailJob = { + ...jobs[0], + isLoading: true, + output: 'testing', + rawPath: `${TEST_HOST}/raw`, + }; + + return createComponentWithStore(Vue.extend(JobDetail), store); + }; + + beforeEach(() => { + vm = createComponent(); + + jest.spyOn(vm, 'fetchJobTrace').mockResolvedValue(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('mounted', () => { + beforeEach(() => { + vm = vm.$mount(); + }); + + it('calls fetchJobTrace', () => { + expect(vm.fetchJobTrace).toHaveBeenCalled(); + }); + + it('scrolls to bottom', () => { + expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalled(); + }); + + it('renders job output', () => { + expect(vm.$el.querySelector('.bash').textContent).toContain('testing'); + }); + + it('renders empty message output', done => { + vm.$store.state.pipelines.detailJob.output = ''; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.bash').textContent).toContain('No messages were logged'); + + done(); + }); + }); + + it('renders loading icon', () => { + expect(vm.$el.querySelector('.build-loader-animation')).not.toBe(null); + expect(vm.$el.querySelector('.build-loader-animation').style.display).toBe(''); + }); + + it('hides output when loading', () => { + expect(vm.$el.querySelector('.bash')).not.toBe(null); + expect(vm.$el.querySelector('.bash').style.display).toBe('none'); + }); + + it('hide loading icon when isLoading is false', done => { + vm.$store.state.pipelines.detailJob.isLoading = false; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.build-loader-animation').style.display).toBe('none'); + + done(); + }); + }); + + it('resets detailJob when clicking header button', () => { + jest.spyOn(vm, 'setDetailJob').mockImplementation(); + + vm.$el.querySelector('.btn').click(); + + expect(vm.setDetailJob).toHaveBeenCalledWith(null); + }); + + it('renders raw path link', () => { + expect(vm.$el.querySelector('.controllers-buttons').getAttribute('href')).toBe( + `${TEST_HOST}/raw`, + ); + }); + }); + + describe('scroll buttons', () => { + beforeEach(() => { + vm = createComponent(); + jest.spyOn(vm, 'fetchJobTrace').mockResolvedValue(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it.each` + fnName | btnName | scrollPos + ${'scrollDown'} | ${'down'} | ${0} + ${'scrollUp'} | ${'up'} | ${1} + `('triggers $fnName when clicking $btnName button', ({ fnName, scrollPos }) => { + jest.spyOn(vm, fnName).mockImplementation(); + + vm = vm.$mount(); + + vm.scrollPos = scrollPos; + + return vm.$nextTick().then(() => { + vm.$el.querySelector('.btn-scroll:not([disabled])').click(); + expect(vm[fnName]).toHaveBeenCalled(); + }); + }); + }); + + describe('scrollDown', () => { + beforeEach(() => { + vm = vm.$mount(); + + jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + }); + + it('scrolls build trace to bottom', () => { + jest.spyOn(vm.$refs.buildTrace, 'scrollHeight', 'get').mockReturnValue(1000); + + vm.scrollDown(); + + expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 1000); + }); + }); + + describe('scrollUp', () => { + beforeEach(() => { + vm = vm.$mount(); + + jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + }); + + it('scrolls build trace to top', () => { + vm.scrollUp(); + + expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 0); + }); + }); + + describe('scrollBuildLog', () => { + beforeEach(() => { + vm = vm.$mount(); + jest.spyOn(vm.$refs.buildTrace, 'scrollTo').mockImplementation(); + jest.spyOn(vm.$refs.buildTrace, 'offsetHeight', 'get').mockReturnValue(100); + jest.spyOn(vm.$refs.buildTrace, 'scrollHeight', 'get').mockReturnValue(200); + }); + + it('sets scrollPos to bottom when at the bottom', () => { + jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(100); + + vm.scrollBuildLog(); + + expect(vm.scrollPos).toBe(1); + }); + + it('sets scrollPos to top when at the top', () => { + jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(0); + vm.scrollPos = 1; + + vm.scrollBuildLog(); + + expect(vm.scrollPos).toBe(0); + }); + + it('resets scrollPos when not at top or bottom', () => { + jest.spyOn(vm.$refs.buildTrace, 'scrollTop', 'get').mockReturnValue(10); + + vm.scrollBuildLog(); + + expect(vm.scrollPos).toBe(''); + }); + }); +}); diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js index 6a2451ad263..b1da89d7a9b 100644 --- a/spec/frontend/ide/components/merge_requests/item_spec.js +++ b/spec/frontend/ide/components/merge_requests/item_spec.js @@ -1,63 +1,91 @@ -import Vue from 'vue'; -import router from '~/ide/ide_router'; +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { createStore } from '~/ide/stores'; +import { createRouter } from '~/ide/ide_router'; import Item from '~/ide/components/merge_requests/item.vue'; -import mountCompontent from '../../../helpers/vue_mount_component_helper'; + +const TEST_ITEM = { + iid: 1, + projectPathWithNamespace: 'gitlab-org/gitlab-ce', + title: 'Merge request title', +}; describe('IDE merge request item', () => { - const Component = Vue.extend(Item); - let vm; + const localVue = createLocalVue(); + localVue.use(Vuex); - beforeEach(() => { - vm = mountCompontent(Component, { - item: { - iid: 1, - projectPathWithNamespace: 'gitlab-org/gitlab-ce', - title: 'Merge request title', + let wrapper; + let store; + let router; + + const createComponent = (props = {}) => { + wrapper = mount(Item, { + propsData: { + item: { + ...TEST_ITEM, + }, + currentId: `${TEST_ITEM.iid}`, + currentProjectId: TEST_ITEM.projectPathWithNamespace, + ...props, }, - currentId: '1', - currentProjectId: 'gitlab-org/gitlab-ce', + localVue, + router, + store, }); + }; + const findIcon = () => wrapper.find('.ic-mobile-issue-close'); + + beforeEach(() => { + store = createStore(); + router = createRouter(store); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); - it('renders merge requests data', () => { - expect(vm.$el.textContent).toContain('Merge request title'); - expect(vm.$el.textContent).toContain('gitlab-org/gitlab-ce!1'); - }); + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders merge requests data', () => { + expect(wrapper.text()).toContain('Merge request title'); + expect(wrapper.text()).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; + it('renders link with href', () => { + const expectedHref = router.resolve( + `/project/${TEST_ITEM.projectPathWithNamespace}/merge_requests/${TEST_ITEM.iid}`, + ).href; - expect(vm.$el.tagName.toLowerCase()).toBe('a'); - expect(vm.$el).toHaveAttr('href', expectedHref); - }); + expect(wrapper.element.tagName.toLowerCase()).toBe('a'); + expect(wrapper.attributes('href')).toBe(expectedHref); + }); - it('renders icon if ID matches currentId', () => { - expect(vm.$el.querySelector('.ic-mobile-issue-close')).not.toBe(null); + it('renders icon if ID matches currentId', () => { + expect(findIcon().exists()).toBe(true); + }); }); - 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); + describe('with different currentId', () => { + beforeEach(() => { + createComponent({ currentId: `${TEST_ITEM.iid + 1}` }); + }); - done(); + it('does not render icon', () => { + expect(findIcon().exists()).toBe(false); }); }); - 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); + describe('with different project ID', () => { + beforeEach(() => { + createComponent({ currentProjectId: 'test/test' }); + }); - done(); + it('does not render icon', () => { + expect(findIcon().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index 62a59a76bf4..da17cc3601e 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -120,6 +120,46 @@ describe('new file modal component', () => { }); }); + describe('createFromTemplate', () => { + let store; + + beforeEach(() => { + store = createStore(); + store.state.entries = { + 'test-path/test': { + name: 'test', + deleted: false, + }, + }; + + vm = createComponentWithStore(Component, store).$mount(); + vm.open('blob'); + + jest.spyOn(vm, 'createTempEntry').mockImplementation(); + }); + + it.each` + entryName | newFilePath + ${''} | ${'.gitignore'} + ${'README.md'} | ${'.gitignore'} + ${'test-path/test/'} | ${'test-path/test/.gitignore'} + ${'test-path/test'} | ${'test-path/.gitignore'} + ${'test-path/test/abc.md'} | ${'test-path/test/.gitignore'} + `( + 'creates a new file with the given template name in appropriate directory for path: $path', + ({ entryName, newFilePath }) => { + vm.entryName = entryName; + + vm.createFromTemplate({ name: '.gitignore' }); + + expect(vm.createTempEntry).toHaveBeenCalledWith({ + name: newFilePath, + type: 'blob', + }); + }, + ); + }); + describe('submitForm', () => { let store; diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index a418fdeb572..ad27954cd10 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -85,7 +85,6 @@ describe('new dropdown upload', () => { name: textFile.name, type: 'blob', content: 'plain text', - base64: false, binary: false, rawPath: '', }); @@ -103,7 +102,6 @@ describe('new dropdown upload', () => { 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/panes/collapsible_sidebar_spec.js b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js index 3bc89996978..e32abc98aae 100644 --- a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js +++ b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js @@ -2,6 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createStore } from '~/ide/stores'; import paneModule from '~/ide/stores/modules/pane'; import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; +import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue'; import Vuex from 'vuex'; const localVue = createLocalVue(); @@ -24,19 +25,15 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { width, ...props, }, - slots: { - 'header-icon': '<div class=".header-icon-slot">SLOT ICON</div>', - header: '<div class=".header-slot"/>', - footer: '<div class=".footer-slot"/>', - }, }); }; - const findTabButton = () => wrapper.find(`[data-qa-selector="${fakeComponentName}_tab_button"]`); + const findSidebarNav = () => wrapper.find(IdeSidebarNav); beforeEach(() => { store = createStore(); store.registerModule('leftPane', paneModule()); + jest.spyOn(store, 'dispatch').mockImplementation(); }); afterEach(() => { @@ -75,92 +72,60 @@ describe('ide/components/panes/collapsible_sidebar.vue', () => { ${'left'} ${'right'} `('when side=$side', ({ side }) => { - it('correctly renders side specific attributes', () => { + beforeEach(() => { createComponent({ extensionTabs, side }); - const button = findTabButton(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.classes()).toContain('multi-file-commit-panel'); - expect(wrapper.classes()).toContain(`ide-${side}-sidebar`); - expect(wrapper.find('.multi-file-commit-panel-inner')).not.toBe(null); - expect(wrapper.find(`.ide-${side}-sidebar-${fakeComponentName}`)).not.toBe(null); - expect(button.attributes('data-placement')).toEqual(side === 'left' ? 'right' : 'left'); - if (side === 'right') { - // this class is only needed on the right side; there is no 'is-left' - expect(button.classes()).toContain('is-right'); - } else { - expect(button.classes()).not.toContain('is-right'); - } - }); }); - }); - - describe('when default side', () => { - let button; - beforeEach(() => { - createComponent({ extensionTabs }); - - button = findTabButton(); + it('correctly renders side specific attributes', () => { + expect(wrapper.classes()).toContain('multi-file-commit-panel'); + expect(wrapper.classes()).toContain(`ide-${side}-sidebar`); + expect(wrapper.find('.multi-file-commit-panel-inner')).not.toBe(null); + expect(wrapper.find(`.ide-${side}-sidebar-${fakeComponentName}`)).not.toBe(null); + expect(findSidebarNav().props('side')).toBe(side); }); - it('correctly renders tab-specific classes', () => { - store.state.rightPane.currentView = fakeComponentName; - - return wrapper.vm.$nextTick().then(() => { - expect(button.classes()).toContain('button-class-1'); - expect(button.classes()).toContain('button-class-2'); - }); + it('nothing is dispatched', () => { + expect(store.dispatch).not.toHaveBeenCalled(); }); - it('can show an open pane tab with an active view', () => { - store.state.rightPane.isOpen = true; - store.state.rightPane.currentView = fakeComponentName; + it('when sidebar emits open, dispatch open', () => { + const view = 'lorem-view'; - return wrapper.vm.$nextTick().then(() => { - expect(button.classes()).toEqual(expect.arrayContaining(['ide-sidebar-link', 'active'])); - expect(button.attributes('data-original-title')).toEqual(fakeComponentName); - expect(wrapper.find('.js-tab-view').exists()).toBe(true); - }); - }); - - it('does not show a pane which is not open', () => { - store.state.rightPane.isOpen = false; - store.state.rightPane.currentView = fakeComponentName; + findSidebarNav().vm.$emit('open', view); - return wrapper.vm.$nextTick().then(() => { - expect(button.classes()).not.toEqual( - expect.arrayContaining(['ide-sidebar-link', 'active']), - ); - expect(wrapper.find('.js-tab-view').exists()).toBe(false); - }); + expect(store.dispatch).toHaveBeenCalledWith(`${side}Pane/open`, view); }); - describe('when button is clicked', () => { - it('opens view', () => { - button.trigger('click'); - expect(store.state.rightPane.isOpen).toBeTruthy(); - }); - - it('toggles open view if tab is currently active', () => { - button.trigger('click'); - expect(store.state.rightPane.isOpen).toBeTruthy(); + it('when sidebar emits close, dispatch toggleOpen', () => { + findSidebarNav().vm.$emit('close'); - button.trigger('click'); - expect(store.state.rightPane.isOpen).toBeFalsy(); - }); + expect(store.dispatch).toHaveBeenCalledWith(`${side}Pane/toggleOpen`); }); + }); - it('shows header-icon', () => { - expect(wrapper.find('.header-icon-slot')).not.toBeNull(); + describe.each` + isOpen + ${true} + ${false} + `('when isOpen=$isOpen', ({ isOpen }) => { + beforeEach(() => { + store.state.rightPane.isOpen = isOpen; + store.state.rightPane.currentView = fakeComponentName; + + createComponent({ extensionTabs }); }); - it('shows header', () => { - expect(wrapper.find('.header-slot')).not.toBeNull(); + it(`tab view is shown=${isOpen}`, () => { + expect(wrapper.find('.js-tab-view').exists()).toBe(isOpen); }); - it('shows footer', () => { - expect(wrapper.find('.footer-slot')).not.toBeNull(); + it('renders sidebar nav', () => { + expect(findSidebarNav().props()).toEqual({ + tabs: extensionTabs, + side: 'right', + currentView: fakeComponentName, + isOpen, + }); }); }); }); diff --git a/spec/frontend/ide/components/panes/right_spec.js b/spec/frontend/ide/components/panes/right_spec.js index 84b2d440b60..203d35ed335 100644 --- a/spec/frontend/ide/components/panes/right_spec.js +++ b/spec/frontend/ide/components/panes/right_spec.js @@ -5,6 +5,7 @@ import { createStore } from '~/ide/stores'; import RightPane from '~/ide/components/panes/right.vue'; import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; import { rightSidebarViews } from '~/ide/constants'; +import extendStore from '~/ide/stores/extend'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -14,6 +15,8 @@ describe('ide/components/panes/right.vue', () => { let store; const createComponent = props => { + extendStore(store, document.createElement('div')); + wrapper = shallowMount(RightPane, { localVue, store, @@ -32,26 +35,6 @@ describe('ide/components/panes/right.vue', () => { wrapper = null; }); - it('allows tabs to be added via extensionTabs prop', () => { - createComponent({ - extensionTabs: [ - { - show: true, - title: 'FakeTab', - }, - ], - }); - - expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - show: true, - title: 'FakeTab', - }), - ]), - ); - }); - describe('pipelines tab', () => { it('is always shown', () => { createComponent(); @@ -99,4 +82,38 @@ describe('ide/components/panes/right.vue', () => { ); }); }); + + describe('terminal tab', () => { + beforeEach(() => { + createComponent(); + }); + + it('adds terminal tab', () => { + store.state.terminal.isVisible = true; + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + show: true, + title: 'Terminal', + }), + ]), + ); + }); + }); + + it('hides terminal tab when not visible', () => { + store.state.terminal.isVisible = false; + + expect(wrapper.find(CollapsibleSidebar).props('extensionTabs')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + show: false, + title: 'Terminal', + }), + ]), + ); + }); + }); }); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index d909a5e478e..795ded35d20 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -6,7 +6,7 @@ import List from '~/ide/components/pipelines/list.vue'; 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 { pipelines } from 'jest/ide/mock_data'; import IDEServices from '~/ide/services'; const localVue = createLocalVue(); diff --git a/spec/frontend/ide/components/repo_commit_section_spec.js b/spec/frontend/ide/components/repo_commit_section_spec.js index 237be018807..3b837622720 100644 --- a/spec/frontend/ide/components/repo_commit_section_spec.js +++ b/spec/frontend/ide/components/repo_commit_section_spec.js @@ -1,7 +1,8 @@ import { mount } from '@vue/test-utils'; import { createStore } from '~/ide/stores'; -import router from '~/ide/ide_router'; +import { createRouter } from '~/ide/ide_router'; import RepoCommitSection from '~/ide/components/repo_commit_section.vue'; +import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue'; import { stageKeys } from '~/ide/constants'; import { file } from '../helpers'; @@ -9,6 +10,7 @@ const TEST_NO_CHANGES_SVG = 'nochangessvg'; describe('RepoCommitSection', () => { let wrapper; + let router; let store; function createComponent() { @@ -54,6 +56,7 @@ describe('RepoCommitSection', () => { beforeEach(() => { store = createStore(); + router = createRouter(store); jest.spyOn(store, 'dispatch'); jest.spyOn(router, 'push').mockImplementation(); @@ -63,7 +66,7 @@ describe('RepoCommitSection', () => { wrapper.destroy(); }); - describe('empty Stage', () => { + describe('empty state', () => { beforeEach(() => { store.state.noChangesStateSvgPath = TEST_NO_CHANGES_SVG; store.state.committedStateSvgPath = 'svg'; @@ -74,11 +77,16 @@ describe('RepoCommitSection', () => { it('renders no changes text', () => { expect( wrapper - .find('.js-empty-state') + .find(EmptyState) .text() .trim(), ).toContain('No changes'); - expect(wrapper.find('.js-empty-state img').attributes('src')).toBe(TEST_NO_CHANGES_SVG); + expect( + wrapper + .find(EmptyState) + .find('img') + .attributes('src'), + ).toBe(TEST_NO_CHANGES_SVG); }); }); @@ -109,6 +117,32 @@ describe('RepoCommitSection', () => { expect(changedFileNames).toEqual(allFiles.map(x => x.path)); }); + + it('does not show empty state', () => { + expect(wrapper.find(EmptyState).exists()).toBe(false); + }); + }); + + describe('if nothing is changed or staged', () => { + beforeEach(() => { + setupDefaultState(); + + store.state.openFiles = [...Object.values(store.state.entries)]; + store.state.openFiles[0].active = true; + store.state.stagedFiles = []; + + createComponent(); + }); + + it('opens currently active file', () => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].pending).toBe(true); + + expect(store.dispatch).toHaveBeenCalledWith('openPendingTab', { + file: store.state.entries[store.getters.activeFile.path], + keyPrefix: stageKeys.unstaged, + }); + }); }); describe('with unstaged file', () => { @@ -129,5 +163,9 @@ describe('RepoCommitSection', () => { keyPrefix: stageKeys.unstaged, }); }); + + it('does not show empty state', () => { + expect(wrapper.find(EmptyState).exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js new file mode 100644 index 00000000000..4967434dfd7 --- /dev/null +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -0,0 +1,664 @@ +import Vuex from 'vuex'; +import Vue from 'vue'; +import MockAdapter from 'axios-mock-adapter'; +import '~/behaviors/markdown/render_gfm'; +import { Range } from 'monaco-editor'; +import axios from '~/lib/utils/axios_utils'; +import { createStoreOptions } from '~/ide/stores'; +import RepoEditor from '~/ide/components/repo_editor.vue'; +import Editor from '~/ide/lib/editor'; +import { leftSidebarViews, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW } from '~/ide/constants'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { file } from '../helpers'; +import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data'; + +describe('RepoEditor', () => { + let vm; + let store; + let mockActions; + + const waitForEditorSetup = () => + new Promise(resolve => { + vm.$once('editorSetup', resolve); + }); + + const createComponent = () => { + if (vm) { + throw new Error('vm already exists'); + } + vm = createComponentWithStore(Vue.extend(RepoEditor), store, { + file: store.state.openFiles[0], + }); + vm.$mount(); + }; + + const createOpenFile = path => { + const origFile = store.state.openFiles[0]; + const newFile = { ...origFile, path, key: path }; + + store.state.entries[path] = newFile; + + store.state.openFiles = [newFile]; + }; + + beforeEach(() => { + mockActions = { + getFileData: jest.fn().mockResolvedValue(), + getRawFileData: jest.fn().mockResolvedValue(), + }; + + const f = { + ...file(), + viewMode: FILE_VIEW_MODE_EDITOR, + }; + + const storeOptions = createStoreOptions(); + storeOptions.actions = { + ...storeOptions.actions, + ...mockActions, + }; + store = new Vuex.Store(storeOptions); + + f.active = true; + f.tempFile = true; + + store.state.openFiles.push(f); + store.state.projects = { + 'gitlab-org/gitlab': { + branches: { + master: { + name: 'master', + commit: { + id: 'abcdefgh', + }, + }, + }, + }, + }; + store.state.currentProjectId = 'gitlab-org/gitlab'; + store.state.currentBranchId = 'master'; + + Vue.set(store.state.entries, f.path, f); + }); + + afterEach(() => { + vm.$destroy(); + vm = null; + + Editor.editorInstance.dispose(); + }); + + const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder'); + + describe('default', () => { + beforeEach(() => { + createComponent(); + + return waitForEditorSetup(); + }); + + it('sets renderWhitespace to `all`', () => { + vm.$store.state.renderWhitespaceInCode = true; + + expect(vm.editorOptions.renderWhitespace).toEqual('all'); + }); + + it('sets renderWhitespace to `none`', () => { + vm.$store.state.renderWhitespaceInCode = false; + + expect(vm.editorOptions.renderWhitespace).toEqual('none'); + }); + + it('renders an ide container', () => { + expect(vm.shouldHideEditor).toBeFalsy(); + expect(vm.showEditor).toBe(true); + expect(findEditor()).not.toHaveCss({ display: 'none' }); + }); + + it('renders only an edit tab', done => { + Vue.nextTick(() => { + const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + + expect(tabs.length).toBe(1); + expect(tabs[0].textContent.trim()).toBe('Edit'); + + done(); + }); + }); + + describe('when file is markdown', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onPost(/(.*)\/preview_markdown/).reply(200, { + body: '<p>testing 123</p>', + }); + + Vue.set(vm, 'file', { + ...vm.file, + projectId: 'namespace/project', + path: 'sample.md', + content: 'testing 123', + }); + + vm.$store.state.entries[vm.file.path] = vm.file; + + return vm.$nextTick(); + }); + + afterEach(() => { + mock.restore(); + }); + + it('renders an Edit and a Preview Tab', done => { + Vue.nextTick(() => { + const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); + + expect(tabs.length).toBe(2); + expect(tabs[0].textContent.trim()).toBe('Edit'); + expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); + + done(); + }); + }); + + it('renders markdown for tempFile', done => { + vm.file.tempFile = true; + + vm.$nextTick() + .then(() => { + vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')[1].click(); + }) + .then(waitForPromises) + .then(() => { + expect(vm.$el.querySelector('.preview-container').innerHTML).toContain( + '<p>testing 123</p>', + ); + }) + .then(done) + .catch(done.fail); + }); + + describe('when not in edit mode', () => { + beforeEach(async () => { + await vm.$nextTick(); + + vm.$store.state.currentActivityView = leftSidebarViews.review.name; + + return vm.$nextTick(); + }); + + it('shows no tabs', () => { + expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0); + }); + }); + }); + + describe('when open file is binary and not raw', () => { + beforeEach(done => { + vm.file.binary = true; + + vm.$nextTick(done); + }); + + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + }); + + describe('createEditorInstance', () => { + it('calls createInstance when viewer is editor', done => { + jest.spyOn(vm.editor, 'createInstance').mockImplementation(); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is diff', done => { + vm.$store.state.viewer = 'diff'; + + jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is a merge request diff', done => { + vm.$store.state.viewer = 'mrdiff'; + + jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('setupEditor', () => { + it('creates new model', () => { + jest.spyOn(vm.editor, 'createModel'); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null); + expect(vm.model).not.toBeNull(); + }); + + it('attaches model to editor', () => { + jest.spyOn(vm.editor, 'attachModel'); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); + }); + + it('attaches model to merge request editor', () => { + vm.$store.state.viewer = 'mrdiff'; + vm.file.mrChange = true; + jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model); + }); + + it('does not attach model to merge request editor when not a MR change', () => { + vm.$store.state.viewer = 'mrdiff'; + vm.file.mrChange = false; + jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model); + }); + + it('adds callback methods', () => { + jest.spyOn(vm.editor, 'onPositionChange'); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.onPositionChange).toHaveBeenCalled(); + expect(vm.model.events.size).toBe(2); + }); + + it('updates state with the value of the model', () => { + vm.model.setValue('testing 1234\n'); + + vm.setupEditor(); + + expect(vm.file.content).toBe('testing 1234\n'); + }); + + it('sets head model as staged file', () => { + jest.spyOn(vm.editor, 'createModel'); + + Editor.editorInstance.modelManager.dispose(); + + vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' }); + vm.file.staged = true; + vm.file.key = `unstaged-${vm.file.key}`; + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); + }); + }); + + describe('editor updateDimensions', () => { + beforeEach(() => { + jest.spyOn(vm.editor, 'updateDimensions'); + jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); + }); + + it('calls updateDimensions when panelResizing is false', done => { + vm.$store.state.panelResizing = true; + + vm.$nextTick() + .then(() => { + vm.$store.state.panelResizing = false; + }) + .then(vm.$nextTick) + .then(() => { + expect(vm.editor.updateDimensions).toHaveBeenCalled(); + expect(vm.editor.updateDiffView).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('does not call updateDimensions when panelResizing is true', done => { + vm.$store.state.panelResizing = true; + + vm.$nextTick(() => { + expect(vm.editor.updateDimensions).not.toHaveBeenCalled(); + expect(vm.editor.updateDiffView).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('calls updateDimensions when rightPane is opened', done => { + vm.$store.state.rightPane.isOpen = true; + + vm.$nextTick(() => { + expect(vm.editor.updateDimensions).toHaveBeenCalled(); + expect(vm.editor.updateDiffView).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('show tabs', () => { + it('shows tabs in edit mode', () => { + expect(vm.$el.querySelector('.nav-links')).not.toBe(null); + }); + + it('hides tabs in review mode', done => { + vm.$store.state.currentActivityView = leftSidebarViews.review.name; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.nav-links')).toBe(null); + + done(); + }); + }); + + it('hides tabs in commit mode', done => { + vm.$store.state.currentActivityView = leftSidebarViews.commit.name; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.nav-links')).toBe(null); + + done(); + }); + }); + }); + + describe('when files view mode is preview', () => { + beforeEach(done => { + jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); + vm.file.viewMode = FILE_VIEW_MODE_PREVIEW; + vm.$nextTick(done); + }); + + it('should hide editor', () => { + expect(vm.showEditor).toBe(false); + expect(findEditor()).toHaveCss({ display: 'none' }); + }); + + describe('when file view mode changes to editor', () => { + it('should update dimensions', () => { + vm.file.viewMode = FILE_VIEW_MODE_EDITOR; + + return vm.$nextTick().then(() => { + expect(vm.editor.updateDimensions).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('initEditor', () => { + beforeEach(() => { + vm.file.tempFile = false; + jest.spyOn(vm.editor, 'createInstance').mockImplementation(); + jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); + }); + + it('does not fetch file information for temp entries', done => { + vm.file.tempFile = true; + + vm.initEditor(); + vm.$nextTick() + .then(() => { + expect(mockActions.getFileData).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('is being initialised for files without content even if shouldHideEditor is `true`', done => { + vm.file.content = ''; + vm.file.raw = ''; + + vm.initEditor(); + vm.$nextTick() + .then(() => { + expect(mockActions.getFileData).toHaveBeenCalled(); + expect(mockActions.getRawFileData).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('does not initialize editor for files already with content', done => { + vm.file.content = 'foo'; + + vm.initEditor(); + vm.$nextTick() + .then(() => { + expect(mockActions.getFileData).not.toHaveBeenCalled(); + expect(mockActions.getRawFileData).not.toHaveBeenCalled(); + expect(vm.editor.createInstance).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updates on file changes', () => { + beforeEach(() => { + jest.spyOn(vm, 'initEditor').mockImplementation(); + }); + + it('calls removePendingTab when old file is pending', done => { + jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); + jest.spyOn(vm, 'removePendingTab').mockImplementation(); + + vm.file.pending = true; + + vm.$nextTick() + .then(() => { + vm.file = file('testing'); + vm.file.content = 'foo'; // need to prevent full cycle of initEditor + + return vm.$nextTick(); + }) + .then(() => { + expect(vm.removePendingTab).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('does not call initEditor if the file did not change', done => { + Vue.set(vm, 'file', vm.file); + + vm.$nextTick() + .then(() => { + expect(vm.initEditor).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('calls initEditor when file key is changed', done => { + expect(vm.initEditor).not.toHaveBeenCalled(); + + Vue.set(vm, 'file', { + ...vm.file, + key: 'new', + }); + + vm.$nextTick() + .then(() => { + expect(vm.initEditor).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('onPaste', () => { + const setFileName = name => { + Vue.set(vm, 'file', { + ...vm.file, + content: 'hello world\n', + name, + path: `foo/${name}`, + key: 'new', + }); + + vm.$store.state.entries[vm.file.path] = vm.file; + }; + + const pasteImage = () => { + window.dispatchEvent( + Object.assign(new Event('paste'), { + clipboardData: { + files: [new File(['foo'], 'foo.png', { type: 'image/png' })], + }, + }), + ); + }; + + const watchState = watched => + new Promise(resolve => { + const unwatch = vm.$store.watch(watched, () => { + unwatch(); + resolve(); + }); + }); + + beforeEach(() => { + setFileName('bar.md'); + + vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] }; + vm.$store.state.currentProjectId = 'gitlab-org'; + vm.$store.state.currentBranchId = 'gitlab'; + + // create a new model each time, otherwise tests conflict with each other + // because of same model being used in multiple tests + Editor.editorInstance.modelManager.dispose(); + vm.setupEditor(); + + return waitForPromises().then(() => { + // set cursor to line 2, column 1 + vm.editor.instance.setSelection(new Range(2, 1, 2, 1)); + vm.editor.instance.focus(); + }); + }); + + it('adds an image entry to the same folder for a pasted image in a markdown file', () => { + pasteImage(); + + return waitForPromises().then(() => { + expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({ + path: 'foo/foo.png', + type: 'blob', + content: 'Zm9v', + binary: true, + rawPath: 'data:image/png;base64,Zm9v', + }); + }); + }); + + it("adds a markdown image tag to the file's contents", () => { + pasteImage(); + + // Pasting an image does a lot of things like using the FileReader API, + // so, waitForPromises isn't very reliable (and causes a flaky spec) + // Read more about state.watch: https://vuex.vuejs.org/api/#watch + return watchState(s => s.entries['foo/bar.md'].content).then(() => { + expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); + }); + }); + + it("does not add file to state or set markdown image syntax if the file isn't markdown", () => { + setFileName('myfile.txt'); + pasteImage(); + + return waitForPromises().then(() => { + expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined(); + expect(vm.file.content).toBe('hello world\n'); + }); + }); + }); + }); + + describe('fetchEditorconfigRules', () => { + beforeEach(() => { + exampleConfigs.forEach(({ path, content }) => { + store.state.entries[path] = { ...file(), path, content }; + }); + }); + + it.each(exampleFiles)( + 'does not fetch content from remote for .editorconfig files present locally (case %#)', + ({ path, monacoRules }) => { + createOpenFile(path); + createComponent(); + + return waitForEditorSetup().then(() => { + expect(vm.rules).toEqual(monacoRules); + expect(vm.model.options).toMatchObject(monacoRules); + expect(mockActions.getFileData).not.toHaveBeenCalled(); + expect(mockActions.getRawFileData).not.toHaveBeenCalled(); + }); + }, + ); + + it('fetches content from remote for .editorconfig files not available locally', () => { + exampleConfigs.forEach(({ path }) => { + delete store.state.entries[path].content; + delete store.state.entries[path].raw; + }); + + // Include a "test" directory which does not exist in store. This one should be skipped. + createOpenFile('foo/bar/baz/test/my_spec.js'); + createComponent(); + + return waitForEditorSetup().then(() => { + expect(mockActions.getFileData.mock.calls.map(([, args]) => args)).toEqual([ + { makeFileActive: false, path: 'foo/bar/baz/.editorconfig' }, + { makeFileActive: false, path: 'foo/bar/.editorconfig' }, + { makeFileActive: false, path: 'foo/.editorconfig' }, + { makeFileActive: false, path: '.editorconfig' }, + ]); + expect(mockActions.getRawFileData.mock.calls.map(([, args]) => args)).toEqual([ + { path: 'foo/bar/baz/.editorconfig' }, + { path: 'foo/bar/.editorconfig' }, + { path: 'foo/.editorconfig' }, + { path: '.editorconfig' }, + ]); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js index 82ea73ffbb1..5a591d3dcd0 100644 --- a/spec/frontend/ide/components/repo_tab_spec.js +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -1,11 +1,13 @@ import Vue from 'vue'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import repoTab from '~/ide/components/repo_tab.vue'; -import router from '~/ide/ide_router'; -import { file, resetStore } from '../helpers'; +import { createRouter } from '~/ide/ide_router'; +import { file } from '../helpers'; describe('RepoTab', () => { let vm; + let store; + let router; function createComponent(propsData) { const RepoTab = Vue.extend(repoTab); @@ -17,13 +19,13 @@ describe('RepoTab', () => { } beforeEach(() => { + store = createStore(); + router = createRouter(store); jest.spyOn(router, 'push').mockImplementation(() => {}); }); afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders a close link and a name link', () => { diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js index 583f71e6121..df5b01770f5 100644 --- a/spec/frontend/ide/components/repo_tabs_spec.js +++ b/spec/frontend/ide/components/repo_tabs_spec.js @@ -16,9 +16,7 @@ describe('RepoTabs', () => { vm = createComponent(RepoTabs, { files: openedFiles, viewer: 'editor', - hasChanges: false, activeFile: file('activeFile'), - hasMergeRequest: false, }); openedFiles[0].active = true; diff --git a/spec/frontend/ide/components/resizable_panel_spec.js b/spec/frontend/ide/components/resizable_panel_spec.js new file mode 100644 index 00000000000..7368de0cee7 --- /dev/null +++ b/spec/frontend/ide/components/resizable_panel_spec.js @@ -0,0 +1,114 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import ResizablePanel from '~/ide/components/resizable_panel.vue'; +import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; +import { SIDE_LEFT, SIDE_RIGHT } from '~/ide/constants'; + +const TEST_WIDTH = 500; +const TEST_MIN_WIDTH = 400; + +describe('~/ide/components/resizable_panel', () => { + const localVue = createLocalVue(); + localVue.use(Vuex); + + let wrapper; + let store; + + beforeEach(() => { + store = new Vuex.Store({}); + jest.spyOn(store, 'dispatch').mockImplementation(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const createComponent = (props = {}) => { + wrapper = shallowMount(ResizablePanel, { + propsData: { + initialWidth: TEST_WIDTH, + minSize: TEST_MIN_WIDTH, + side: SIDE_LEFT, + ...props, + }, + store, + localVue, + }); + }; + const findResizer = () => wrapper.find(PanelResizer); + const findInlineStyle = () => wrapper.element.style.cssText; + const createInlineStyle = width => `width: ${width}px;`; + + describe.each` + props | showResizer | resizerSide | expectedStyle + ${{ resizable: true, side: SIDE_LEFT }} | ${true} | ${SIDE_RIGHT} | ${createInlineStyle(TEST_WIDTH)} + ${{ resizable: true, side: SIDE_RIGHT }} | ${true} | ${SIDE_LEFT} | ${createInlineStyle(TEST_WIDTH)} + ${{ resizable: false, side: SIDE_LEFT }} | ${false} | ${SIDE_RIGHT} | ${''} + `('with props $props', ({ props, showResizer, resizerSide, expectedStyle }) => { + beforeEach(() => { + createComponent(props); + }); + + it(`show resizer is ${showResizer}`, () => { + const expectedDisplay = showResizer ? '' : 'none'; + const resizer = findResizer(); + + expect(resizer.exists()).toBe(true); + expect(resizer.element.style.display).toBe(expectedDisplay); + }); + + it(`resizer side is '${resizerSide}'`, () => { + const resizer = findResizer(); + + expect(resizer.props('side')).toBe(resizerSide); + }); + + it(`has style '${expectedStyle}'`, () => { + expect(findInlineStyle()).toBe(expectedStyle); + }); + }); + + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not dispatch anything', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it.each` + event | dispatchArgs + ${'resize-start'} | ${['setResizingStatus', true]} + ${'resize-end'} | ${['setResizingStatus', false]} + `('when resizer emits $event, dispatch $dispatchArgs', ({ event, dispatchArgs }) => { + const resizer = findResizer(); + + resizer.vm.$emit(event); + + expect(store.dispatch).toHaveBeenCalledWith(...dispatchArgs); + }); + + it('renders resizer', () => { + const resizer = findResizer(); + + expect(resizer.props()).toMatchObject({ + maxSize: window.innerWidth / 2, + minSize: TEST_MIN_WIDTH, + startSize: TEST_WIDTH, + }); + }); + + it('when resizer emits update:size, changes inline width', () => { + const newSize = TEST_WIDTH - 100; + const resizer = findResizer(); + + resizer.vm.$emit('update:size', newSize); + + return wrapper.vm.$nextTick().then(() => { + expect(findInlineStyle()).toBe(createInlineStyle(newSize)); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/terminal/empty_state_spec.js b/spec/frontend/ide/components/terminal/empty_state_spec.js new file mode 100644 index 00000000000..a3f2089608d --- /dev/null +++ b/spec/frontend/ide/components/terminal/empty_state_spec.js @@ -0,0 +1,107 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { TEST_HOST } from 'spec/test_constants'; +import TerminalEmptyState from '~/ide/components/terminal/empty_state.vue'; + +const TEST_HELP_PATH = `${TEST_HOST}/help/test`; +const TEST_PATH = `${TEST_HOST}/home.png`; +const TEST_HTML_MESSAGE = 'lorem <strong>ipsum</strong>'; + +describe('IDE TerminalEmptyState', () => { + let wrapper; + + const factory = (options = {}) => { + wrapper = shallowMount(TerminalEmptyState, { + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('does not show illustration, if no path specified', () => { + factory(); + + expect(wrapper.find('.svg-content').exists()).toBe(false); + }); + + it('shows illustration with path', () => { + factory({ + propsData: { + illustrationPath: TEST_PATH, + }, + }); + + const img = wrapper.find('.svg-content img'); + + expect(img.exists()).toBe(true); + expect(img.attributes('src')).toEqual(TEST_PATH); + }); + + it('when loading, shows loading icon', () => { + factory({ + propsData: { + isLoading: true, + }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('when not loading, does not show loading icon', () => { + factory({ + propsData: { + isLoading: false, + }, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + + describe('when valid', () => { + let button; + + beforeEach(() => { + factory({ + propsData: { + isLoading: false, + isValid: true, + helpPath: TEST_HELP_PATH, + }, + }); + + button = wrapper.find('button'); + }); + + it('shows button', () => { + expect(button.text()).toEqual('Start Web Terminal'); + expect(button.attributes('disabled')).toBeFalsy(); + }); + + it('emits start when button is clicked', () => { + expect(wrapper.emitted().start).toBeFalsy(); + + button.trigger('click'); + + expect(wrapper.emitted().start).toHaveLength(1); + }); + + it('shows help path link', () => { + expect(wrapper.find('a').attributes('href')).toEqual(TEST_HELP_PATH); + }); + }); + + it('when not valid, shows disabled button and message', () => { + factory({ + propsData: { + isLoading: false, + isValid: false, + message: TEST_HTML_MESSAGE, + }, + }); + + expect(wrapper.find('button').attributes('disabled')).not.toBe(null); + expect(wrapper.find('.bs-callout').element.innerHTML).toEqual(TEST_HTML_MESSAGE); + }); +}); diff --git a/spec/frontend/ide/components/terminal/session_spec.js b/spec/frontend/ide/components/terminal/session_spec.js new file mode 100644 index 00000000000..2399446ed15 --- /dev/null +++ b/spec/frontend/ide/components/terminal/session_spec.js @@ -0,0 +1,96 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import TerminalSession from '~/ide/components/terminal/session.vue'; +import Terminal from '~/ide/components/terminal/terminal.vue'; +import { + STARTING, + PENDING, + RUNNING, + STOPPING, + STOPPED, +} from '~/ide/stores/modules/terminal/constants'; + +const TEST_TERMINAL_PATH = 'terminal/path'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('IDE TerminalSession', () => { + let wrapper; + let actions; + let state; + + const factory = (options = {}) => { + const store = new Vuex.Store({ + modules: { + terminal: { + namespaced: true, + actions, + state, + }, + }, + }); + + wrapper = shallowMount(TerminalSession, { + localVue, + store, + ...options, + }); + }; + + beforeEach(() => { + state = { + session: { status: RUNNING, terminalPath: TEST_TERMINAL_PATH }, + }; + actions = { + restartSession: jest.fn(), + stopSession: jest.fn(), + }; + }); + + it('is empty if session is falsey', () => { + state.session = null; + factory(); + + expect(wrapper.isEmpty()).toBe(true); + }); + + it('shows terminal', () => { + factory(); + + expect(wrapper.find(Terminal).props()).toEqual({ + terminalPath: TEST_TERMINAL_PATH, + status: RUNNING, + }); + }); + + [STARTING, PENDING, RUNNING].forEach(status => { + it(`show stop button when status is ${status}`, () => { + state.session = { status }; + factory(); + + const button = wrapper.find('button'); + button.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(button.text()).toEqual('Stop Terminal'); + expect(actions.stopSession).toHaveBeenCalled(); + }); + }); + }); + + [STOPPING, STOPPED].forEach(status => { + it(`show stop button when status is ${status}`, () => { + state.session = { status }; + factory(); + + const button = wrapper.find('button'); + button.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(button.text()).toEqual('Restart Terminal'); + expect(actions.restartSession).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/components/terminal/terminal_controls_spec.js b/spec/frontend/ide/components/terminal/terminal_controls_spec.js new file mode 100644 index 00000000000..6c2871abb46 --- /dev/null +++ b/spec/frontend/ide/components/terminal/terminal_controls_spec.js @@ -0,0 +1,65 @@ +import { shallowMount } from '@vue/test-utils'; +import TerminalControls from '~/ide/components/terminal/terminal_controls.vue'; +import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue'; + +describe('IDE TerminalControls', () => { + let wrapper; + let buttons; + + const factory = (options = {}) => { + wrapper = shallowMount(TerminalControls, { + ...options, + }); + + buttons = wrapper.findAll(ScrollButton); + }; + + it('shows an up and down scroll button', () => { + factory(); + + expect(buttons.wrappers.map(x => x.props())).toEqual([ + expect.objectContaining({ direction: 'up', disabled: true }), + expect.objectContaining({ direction: 'down', disabled: true }), + ]); + }); + + it('enables up button with prop', () => { + factory({ propsData: { canScrollUp: true } }); + + expect(buttons.at(0).props()).toEqual( + expect.objectContaining({ direction: 'up', disabled: false }), + ); + }); + + it('enables down button with prop', () => { + factory({ propsData: { canScrollDown: true } }); + + expect(buttons.at(1).props()).toEqual( + expect.objectContaining({ direction: 'down', disabled: false }), + ); + }); + + it('emits "scroll-up" when click up button', () => { + factory({ propsData: { canScrollUp: true } }); + + expect(wrapper.emittedByOrder()).toEqual([]); + + buttons.at(0).vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-up', args: [] }]); + }); + }); + + it('emits "scroll-down" when click down button', () => { + factory({ propsData: { canScrollDown: true } }); + + expect(wrapper.emittedByOrder()).toEqual([]); + + buttons.at(1).vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-down', args: [] }]); + }); + }); +}); diff --git a/spec/frontend/ide/components/terminal/terminal_spec.js b/spec/frontend/ide/components/terminal/terminal_spec.js new file mode 100644 index 00000000000..3095288bb28 --- /dev/null +++ b/spec/frontend/ide/components/terminal/terminal_spec.js @@ -0,0 +1,225 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import Terminal from '~/ide/components/terminal/terminal.vue'; +import TerminalControls from '~/ide/components/terminal/terminal_controls.vue'; +import { + STARTING, + PENDING, + RUNNING, + STOPPING, + STOPPED, +} from '~/ide/stores/modules/terminal/constants'; +import GLTerminal from '~/terminal/terminal'; + +const TEST_TERMINAL_PATH = 'terminal/path'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +jest.mock('~/terminal/terminal', () => + jest.fn().mockImplementation(() => ({ + dispose: jest.fn(), + disable: jest.fn(), + addScrollListener: jest.fn(), + scrollToTop: jest.fn(), + scrollToBottom: jest.fn(), + })), +); + +describe('IDE Terminal', () => { + let wrapper; + let state; + + const factory = propsData => { + const store = new Vuex.Store({ + state, + mutations: { + set(prevState, newState) { + Object.assign(prevState, newState); + }, + }, + }); + + wrapper = shallowMount(localVue.extend(Terminal), { + propsData: { + status: RUNNING, + terminalPath: TEST_TERMINAL_PATH, + ...propsData, + }, + localVue, + store, + }); + }; + + beforeEach(() => { + state = { + panelResizing: false, + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading text', () => { + [STARTING, PENDING].forEach(status => { + it(`shows when starting (${status})`, () => { + factory({ status }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find('.top-bar').text()).toBe('Starting...'); + }); + }); + + it(`shows when stopping`, () => { + factory({ status: STOPPING }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find('.top-bar').text()).toBe('Stopping...'); + }); + + [RUNNING, STOPPED].forEach(status => { + it('hides when not loading', () => { + factory({ status }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('.top-bar').text()).toBe(''); + }); + }); + }); + + describe('refs.terminal', () => { + it('has terminal path in data', () => { + factory(); + + expect(wrapper.vm.$refs.terminal.dataset.projectPath).toBe(TEST_TERMINAL_PATH); + }); + }); + + describe('terminal controls', () => { + beforeEach(() => { + factory(); + wrapper.vm.createTerminal(); + + return localVue.nextTick(); + }); + + it('is visible if terminal is created', () => { + expect(wrapper.find(TerminalControls).exists()).toBe(true); + }); + + it('scrolls glterminal on scroll-up', () => { + wrapper.find(TerminalControls).vm.$emit('scroll-up'); + + expect(wrapper.vm.glterminal.scrollToTop).toHaveBeenCalled(); + }); + + it('scrolls glterminal on scroll-down', () => { + wrapper.find(TerminalControls).vm.$emit('scroll-down'); + + expect(wrapper.vm.glterminal.scrollToBottom).toHaveBeenCalled(); + }); + + it('has props set', () => { + expect(wrapper.find(TerminalControls).props()).toEqual({ + canScrollUp: false, + canScrollDown: false, + }); + + wrapper.setData({ canScrollUp: true, canScrollDown: true }); + + return localVue.nextTick().then(() => { + expect(wrapper.find(TerminalControls).props()).toEqual({ + canScrollUp: true, + canScrollDown: true, + }); + }); + }); + }); + + describe('refresh', () => { + let createTerminal; + let stopTerminal; + + beforeEach(() => { + createTerminal = jest.fn().mockName('createTerminal'); + stopTerminal = jest.fn().mockName('stopTerminal'); + }); + + it('creates the terminal if running', () => { + factory({ status: RUNNING, terminalPath: TEST_TERMINAL_PATH }); + + wrapper.setMethods({ createTerminal }); + wrapper.vm.refresh(); + + expect(createTerminal).toHaveBeenCalled(); + }); + + it('stops the terminal if stopping', () => { + factory({ status: STOPPING }); + + wrapper.setMethods({ stopTerminal }); + wrapper.vm.refresh(); + + expect(stopTerminal).toHaveBeenCalled(); + }); + }); + + describe('createTerminal', () => { + beforeEach(() => { + factory(); + wrapper.vm.createTerminal(); + }); + + it('creates the terminal', () => { + expect(GLTerminal).toHaveBeenCalledWith(wrapper.vm.$refs.terminal); + expect(wrapper.vm.glterminal).toBeTruthy(); + }); + + describe('scroll listener', () => { + it('has been called', () => { + expect(wrapper.vm.glterminal.addScrollListener).toHaveBeenCalled(); + }); + + it('updates scroll data when called', () => { + expect(wrapper.vm.canScrollUp).toBe(false); + expect(wrapper.vm.canScrollDown).toBe(false); + + const listener = wrapper.vm.glterminal.addScrollListener.mock.calls[0][0]; + listener({ canScrollUp: true, canScrollDown: true }); + + expect(wrapper.vm.canScrollUp).toBe(true); + expect(wrapper.vm.canScrollDown).toBe(true); + }); + }); + }); + + describe('destroyTerminal', () => { + it('calls dispose', () => { + factory(); + wrapper.vm.createTerminal(); + const disposeSpy = wrapper.vm.glterminal.dispose; + + expect(disposeSpy).not.toHaveBeenCalled(); + + wrapper.vm.destroyTerminal(); + + expect(disposeSpy).toHaveBeenCalled(); + expect(wrapper.vm.glterminal).toBe(null); + }); + }); + + describe('stopTerminal', () => { + it('calls disable', () => { + factory(); + wrapper.vm.createTerminal(); + + expect(wrapper.vm.glterminal.disable).not.toHaveBeenCalled(); + + wrapper.vm.stopTerminal(); + + expect(wrapper.vm.glterminal.disable).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/ide/components/terminal/view_spec.js b/spec/frontend/ide/components/terminal/view_spec.js new file mode 100644 index 00000000000..eff200550da --- /dev/null +++ b/spec/frontend/ide/components/terminal/view_spec.js @@ -0,0 +1,91 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { TEST_HOST } from 'spec/test_constants'; +import TerminalEmptyState from '~/ide/components/terminal/empty_state.vue'; +import TerminalView from '~/ide/components/terminal/view.vue'; +import TerminalSession from '~/ide/components/terminal/session.vue'; + +const TEST_HELP_PATH = `${TEST_HOST}/help`; +const TEST_SVG_PATH = `${TEST_HOST}/illustration.svg`; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('IDE TerminalView', () => { + let state; + let actions; + let getters; + let wrapper; + + const factory = () => { + const store = new Vuex.Store({ + modules: { + terminal: { + namespaced: true, + state, + actions, + getters, + }, + }, + }); + + wrapper = shallowMount(TerminalView, { localVue, store }); + }; + + beforeEach(() => { + state = { + isShowSplash: true, + paths: { + webTerminalHelpPath: TEST_HELP_PATH, + webTerminalSvgPath: TEST_SVG_PATH, + }, + }; + + actions = { + hideSplash: jest.fn().mockName('hideSplash'), + startSession: jest.fn().mockName('startSession'), + }; + + getters = { + allCheck: () => ({ + isLoading: false, + isValid: false, + message: 'bad', + }), + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders empty state', () => { + factory(); + + expect(wrapper.find(TerminalEmptyState).props()).toEqual({ + helpPath: TEST_HELP_PATH, + illustrationPath: TEST_SVG_PATH, + ...getters.allCheck(), + }); + }); + + it('hides splash and starts, when started', () => { + factory(); + + expect(actions.startSession).not.toHaveBeenCalled(); + expect(actions.hideSplash).not.toHaveBeenCalled(); + + wrapper.find(TerminalEmptyState).vm.$emit('start'); + + expect(actions.startSession).toHaveBeenCalled(); + expect(actions.hideSplash).toHaveBeenCalled(); + }); + + it('shows Web Terminal when started', () => { + state.isShowSplash = false; + factory(); + + expect(wrapper.find(TerminalEmptyState).exists()).toBe(false); + expect(wrapper.find(TerminalSession).exists()).toBe(true); + }); +}); diff --git a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js new file mode 100644 index 00000000000..afdecb7bbbd --- /dev/null +++ b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js @@ -0,0 +1,47 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import TerminalSyncStatus from '~/ide/components/terminal_sync/terminal_sync_status.vue'; +import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync_status_safe.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ide/components/terminal_sync/terminal_sync_status_safe', () => { + let store; + let wrapper; + + const createComponent = () => { + store = new Vuex.Store({ + state: {}, + }); + + wrapper = shallowMount(TerminalSyncStatusSafe, { + localVue, + store, + }); + }; + + beforeEach(createComponent); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with terminal sync module in store', () => { + beforeEach(() => { + store.registerModule('terminalSync', { + state: {}, + }); + }); + + it('renders terminal sync status', () => { + expect(wrapper.find(TerminalSyncStatus).exists()).toBe(true); + }); + }); + + describe('without terminal sync module', () => { + it('does not render terminal sync status', () => { + expect(wrapper.find(TerminalSyncStatus).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js new file mode 100644 index 00000000000..16a76fae1dd --- /dev/null +++ b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js @@ -0,0 +1,99 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import TerminalSyncStatus from '~/ide/components/terminal_sync/terminal_sync_status.vue'; +import { + MSG_TERMINAL_SYNC_CONNECTING, + MSG_TERMINAL_SYNC_UPLOADING, + MSG_TERMINAL_SYNC_RUNNING, +} from '~/ide/stores/modules/terminal_sync/messages'; +import Icon from '~/vue_shared/components/icon.vue'; + +const TEST_MESSAGE = 'lorem ipsum dolar sit'; +const START_LOADING = 'START_LOADING'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ide/components/terminal_sync/terminal_sync_status', () => { + let moduleState; + let store; + let wrapper; + + const createComponent = () => { + store = new Vuex.Store({ + modules: { + terminalSync: { + namespaced: true, + state: moduleState, + mutations: { + [START_LOADING]: state => { + state.isLoading = true; + }, + }, + }, + }, + }); + + wrapper = shallowMount(TerminalSyncStatus, { + localVue, + store, + }); + }; + + beforeEach(() => { + moduleState = { + isLoading: false, + isStarted: false, + isError: false, + message: '', + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when doing nothing', () => { + it('shows nothing', () => { + createComponent(); + + expect(wrapper.isEmpty()).toBe(true); + }); + }); + + describe.each` + description | state | statusMessage | icon + ${'when loading'} | ${{ isLoading: true }} | ${MSG_TERMINAL_SYNC_CONNECTING} | ${''} + ${'when loading and started'} | ${{ isLoading: true, isStarted: true }} | ${MSG_TERMINAL_SYNC_UPLOADING} | ${''} + ${'when error'} | ${{ isError: true, message: TEST_MESSAGE }} | ${TEST_MESSAGE} | ${'warning'} + ${'when started'} | ${{ isStarted: true }} | ${MSG_TERMINAL_SYNC_RUNNING} | ${'mobile-issue-close'} + `('$description', ({ state, statusMessage, icon }) => { + beforeEach(() => { + Object.assign(moduleState, state); + createComponent(); + }); + + it('shows message', () => { + expect(wrapper.attributes('title')).toContain(statusMessage); + }); + + if (!icon) { + it('does not render icon', () => { + expect(wrapper.find(Icon).exists()).toBe(false); + }); + + it('renders loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + } else { + it('renders icon', () => { + expect(wrapper.find(Icon).props('name')).toEqual(icon); + }); + + it('does not render loading icon', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + }); + } + }); +}); diff --git a/spec/frontend/ide/file_helpers.js b/spec/frontend/ide/file_helpers.js new file mode 100644 index 00000000000..326f8b9716d --- /dev/null +++ b/spec/frontend/ide/file_helpers.js @@ -0,0 +1,35 @@ +export const createFile = (path, content = '') => ({ + id: path, + path, + content, + raw: content, +}); + +export const createNewFile = (path, content) => + Object.assign(createFile(path, content), { + tempFile: true, + raw: '', + }); + +export const createDeletedFile = (path, content) => + Object.assign(createFile(path, content), { + deleted: true, + }); + +export const createUpdatedFile = (path, oldContent, content) => + Object.assign(createFile(path, content), { + raw: oldContent, + }); + +export const createMovedFile = (path, prevPath, content) => + Object.assign(createNewFile(path, content), { + prevPath, + }); + +export const createEntries = path => + path.split('/').reduce((acc, part, idx, parts) => { + const parentPath = parts.slice(0, idx).join('/'); + const fullPath = parentPath ? `${parentPath}/${part}` : part; + + return Object.assign(acc, { [fullPath]: { ...createFile(fullPath), parentPath } }); + }, {}); diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js index 1461b756d13..b53e2019819 100644 --- a/spec/frontend/ide/ide_router_spec.js +++ b/spec/frontend/ide/ide_router_spec.js @@ -1,17 +1,20 @@ -import router from '~/ide/ide_router'; -import store from '~/ide/stores'; +import { createRouter } from '~/ide/ide_router'; +import { createStore } from '~/ide/stores'; +import waitForPromises from 'helpers/wait_for_promises'; describe('IDE router', () => { const PROJECT_NAMESPACE = 'my-group/sub-group'; const PROJECT_NAME = 'my-project'; + const TEST_PATH = `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}/merge_requests/2`; - afterEach(() => { - router.push('/'); - }); + let store; + let router; - afterAll(() => { - // VueRouter leaves this window.history at the "base" url. We need to clean this up. + beforeEach(() => { window.history.replaceState({}, '', '/'); + store = createStore(); + router = createRouter(store); + jest.spyOn(store, 'dispatch').mockReturnValue(new Promise(() => {})); }); [ @@ -31,8 +34,6 @@ describe('IDE router', () => { `/project/${PROJECT_NAMESPACE}/${PROJECT_NAME}`, ].forEach(route => { it(`finds project path when route is "${route}"`, () => { - jest.spyOn(store, 'dispatch').mockReturnValue(new Promise(() => {})); - router.push(route); expect(store.dispatch).toHaveBeenCalledWith('getProjectData', { @@ -41,4 +42,22 @@ describe('IDE router', () => { }); }); }); + + it('keeps router in sync when store changes', async () => { + expect(router.currentRoute.fullPath).toBe('/'); + + store.state.router.fullPath = TEST_PATH; + + await waitForPromises(); + + expect(router.currentRoute.fullPath).toBe(TEST_PATH); + }); + + it('keeps store in sync when router changes', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + + router.push(TEST_PATH); + + expect(store.dispatch).toHaveBeenCalledWith('router/push', TEST_PATH, { root: true }); + }); }); diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js index 2ef2f0da6da..df46b7774b0 100644 --- a/spec/frontend/ide/lib/common/model_spec.js +++ b/spec/frontend/ide/lib/common/model_spec.js @@ -133,5 +133,77 @@ describe('Multi-file editor library model', () => { expect(disposeSpy).toHaveBeenCalled(); }); + + it('applies custom options and triggers onChange callback', () => { + const changeSpy = jest.fn(); + jest.spyOn(model, 'applyCustomOptions'); + + model.onChange(changeSpy); + + model.dispose(); + + expect(model.applyCustomOptions).toHaveBeenCalled(); + expect(changeSpy).toHaveBeenCalled(); + }); + }); + + describe('updateOptions', () => { + it('sets the options on the options object', () => { + model.updateOptions({ insertSpaces: true, someOption: 'some value' }); + + expect(model.options).toEqual({ + endOfLine: 0, + insertFinalNewline: true, + insertSpaces: true, + someOption: 'some value', + trimTrailingWhitespace: false, + }); + }); + + it.each` + option | value + ${'insertSpaces'} | ${true} + ${'insertSpaces'} | ${false} + ${'indentSize'} | ${4} + ${'tabSize'} | ${3} + `("correctly sets option: $option=$value to Monaco's TextModel", ({ option, value }) => { + model.updateOptions({ [option]: value }); + + expect(model.getModel().getOptions()).toMatchObject({ [option]: value }); + }); + + it('applies custom options immediately', () => { + jest.spyOn(model, 'applyCustomOptions'); + + model.updateOptions({ trimTrailingWhitespace: true, someOption: 'some value' }); + + expect(model.applyCustomOptions).toHaveBeenCalled(); + }); + }); + + describe('applyCustomOptions', () => { + it.each` + option | value | contentBefore | contentAfter + ${'endOfLine'} | ${0} | ${'hello\nworld\n'} | ${'hello\nworld\n'} + ${'endOfLine'} | ${0} | ${'hello\r\nworld\r\n'} | ${'hello\nworld\n'} + ${'endOfLine'} | ${1} | ${'hello\nworld\n'} | ${'hello\r\nworld\r\n'} + ${'endOfLine'} | ${1} | ${'hello\r\nworld\r\n'} | ${'hello\r\nworld\r\n'} + ${'insertFinalNewline'} | ${true} | ${'hello\nworld'} | ${'hello\nworld\n'} + ${'insertFinalNewline'} | ${true} | ${'hello\nworld\n'} | ${'hello\nworld\n'} + ${'insertFinalNewline'} | ${false} | ${'hello\nworld'} | ${'hello\nworld'} + ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\nworld \t\n'} | ${'hello\nworld\n'} + ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\r\nworld \t\r\n'} | ${'hello\nworld\n'} + ${'trimTrailingWhitespace'} | ${false} | ${'hello \t\r\nworld \t\r\n'} | ${'hello \t\nworld \t\n'} + `( + 'correctly applies custom option $option=$value to content', + ({ option, value, contentBefore, contentAfter }) => { + model.options[option] = value; + + model.updateNewContent(contentBefore); + model.applyCustomOptions(); + + expect(model.getModel().getValue()).toEqual(contentAfter); + }, + ); }); }); diff --git a/spec/frontend/ide/lib/create_diff_spec.js b/spec/frontend/ide/lib/create_diff_spec.js new file mode 100644 index 00000000000..273f9ee27bd --- /dev/null +++ b/spec/frontend/ide/lib/create_diff_spec.js @@ -0,0 +1,182 @@ +import createDiff from '~/ide/lib/create_diff'; +import createFileDiff from '~/ide/lib/create_file_diff'; +import { commitActionTypes } from '~/ide/constants'; +import { + createNewFile, + createUpdatedFile, + createDeletedFile, + createMovedFile, + createEntries, +} from '../file_helpers'; + +const PATH_FOO = 'test/foo.md'; +const PATH_BAR = 'test/bar.md'; +const PATH_ZED = 'test/zed.md'; +const PATH_LOREM = 'test/lipsum/nested/lorem.md'; +const PATH_IPSUM = 'test/lipsum/ipsum.md'; +const TEXT = `Lorem ipsum dolor sit amet, +consectetur adipiscing elit. +Morbi ex dolor, euismod nec rutrum nec, egestas at ligula. +Praesent scelerisque ut nisi eu eleifend. +Suspendisse potenti. +`; +const LINES = TEXT.trim().split('\n'); + +const joinDiffs = (...patches) => patches.join(''); + +describe('IDE lib/create_diff', () => { + it('with created files, generates patch', () => { + const changedFiles = [createNewFile(PATH_FOO, TEXT), createNewFile(PATH_BAR, '')]; + const result = createDiff({ changedFiles }); + + expect(result).toEqual({ + patch: joinDiffs( + createFileDiff(changedFiles[0], commitActionTypes.create), + createFileDiff(changedFiles[1], commitActionTypes.create), + ), + toDelete: [], + }); + }); + + it('with deleted files, adds to delete', () => { + const changedFiles = [createDeletedFile(PATH_FOO, TEXT), createDeletedFile(PATH_BAR, '')]; + + const result = createDiff({ changedFiles }); + + expect(result).toEqual({ + patch: '', + toDelete: [PATH_FOO, PATH_BAR], + }); + }); + + it('with updated files, generates patch', () => { + const changedFiles = [createUpdatedFile(PATH_FOO, TEXT, 'A change approaches!')]; + + const result = createDiff({ changedFiles }); + + expect(result).toEqual({ + patch: createFileDiff(changedFiles[0], commitActionTypes.update), + toDelete: [], + }); + }); + + it('with files in both staged and changed, prefer changed', () => { + const changedFiles = [ + createUpdatedFile(PATH_FOO, TEXT, 'Do a change!'), + createDeletedFile(PATH_LOREM), + ]; + + const result = createDiff({ + changedFiles, + stagedFiles: [createUpdatedFile(PATH_LOREM, TEXT, ''), createDeletedFile(PATH_FOO, TEXT)], + }); + + expect(result).toEqual({ + patch: createFileDiff(changedFiles[0], commitActionTypes.update), + toDelete: [PATH_LOREM], + }); + }); + + it('with file created in staging and deleted in changed, do nothing', () => { + const result = createDiff({ + changedFiles: [createDeletedFile(PATH_FOO)], + stagedFiles: [createNewFile(PATH_FOO, TEXT)], + }); + + expect(result).toEqual({ + patch: '', + toDelete: [], + }); + }); + + it('with file deleted in both staged and changed, delete', () => { + const result = createDiff({ + changedFiles: [createDeletedFile(PATH_LOREM)], + stagedFiles: [createDeletedFile(PATH_LOREM)], + }); + + expect(result).toEqual({ + patch: '', + toDelete: [PATH_LOREM], + }); + }); + + it('with file moved, create and delete', () => { + const changedFiles = [createMovedFile(PATH_BAR, PATH_FOO, TEXT)]; + + const result = createDiff({ + changedFiles, + stagedFiles: [createDeletedFile(PATH_FOO)], + }); + + expect(result).toEqual({ + patch: createFileDiff(changedFiles[0], commitActionTypes.create), + toDelete: [PATH_FOO], + }); + }); + + it('with file moved and no content, move', () => { + const changedFiles = [createMovedFile(PATH_BAR, PATH_FOO)]; + + const result = createDiff({ + changedFiles, + stagedFiles: [createDeletedFile(PATH_FOO)], + }); + + expect(result).toEqual({ + patch: createFileDiff(changedFiles[0], commitActionTypes.move), + toDelete: [], + }); + }); + + it('creates a well formatted patch', () => { + const changedFiles = [ + createMovedFile(PATH_BAR, PATH_FOO), + createDeletedFile(PATH_ZED), + createNewFile(PATH_LOREM, TEXT), + createUpdatedFile(PATH_IPSUM, TEXT, "That's all folks!"), + ]; + + const expectedPatch = `diff --git "a/${PATH_FOO}" "b/${PATH_BAR}" +rename from ${PATH_FOO} +rename to ${PATH_BAR} +diff --git "a/${PATH_LOREM}" "b/${PATH_LOREM}" +new file mode 100644 +--- /dev/null ++++ b/${PATH_LOREM} +@@ -0,0 +1,${LINES.length} @@ +${LINES.map(line => `+${line}`).join('\n')} +diff --git "a/${PATH_IPSUM}" "b/${PATH_IPSUM}" +--- a/${PATH_IPSUM} ++++ b/${PATH_IPSUM} +@@ -1,${LINES.length} +1,1 @@ +${LINES.map(line => `-${line}`).join('\n')} ++That's all folks! +\\ No newline at end of file +`; + + const result = createDiff({ changedFiles }); + + expect(result).toEqual({ + patch: expectedPatch, + toDelete: [PATH_ZED], + }); + }); + + it('deletes deleted parent directories', () => { + const deletedFiles = ['foo/bar/zed/test.md', 'foo/bar/zed/test2.md']; + const entries = deletedFiles.reduce((acc, path) => Object.assign(acc, createEntries(path)), {}); + const allDeleted = [...deletedFiles, 'foo/bar/zed', 'foo/bar']; + allDeleted.forEach(path => { + entries[path].deleted = true; + }); + const changedFiles = deletedFiles.map(x => entries[x]); + + const result = createDiff({ changedFiles, entries }); + + expect(result).toEqual({ + patch: '', + toDelete: allDeleted, + }); + }); +}); diff --git a/spec/frontend/ide/lib/create_file_diff_spec.js b/spec/frontend/ide/lib/create_file_diff_spec.js new file mode 100644 index 00000000000..4b428468a6d --- /dev/null +++ b/spec/frontend/ide/lib/create_file_diff_spec.js @@ -0,0 +1,163 @@ +import createFileDiff from '~/ide/lib/create_file_diff'; +import { commitActionTypes } from '~/ide/constants'; +import { + createUpdatedFile, + createNewFile, + createMovedFile, + createDeletedFile, +} from '../file_helpers'; + +const PATH = 'test/numbers.md'; +const PATH_FOO = 'test/foo.md'; +const TEXT_LINE_COUNT = 100; +const TEXT = Array(TEXT_LINE_COUNT) + .fill(0) + .map((_, idx) => `${idx + 1}`) + .join('\n'); + +const spliceLines = (content, lineNumber, deleteCount = 0, newLines = []) => { + const lines = content.split('\n'); + lines.splice(lineNumber, deleteCount, ...newLines); + return lines.join('\n'); +}; + +const mapLines = (content, mapFn) => + content + .split('\n') + .map(mapFn) + .join('\n'); + +describe('IDE lib/create_file_diff', () => { + it('returns empty string with "garbage" action', () => { + const result = createFileDiff(createNewFile(PATH, ''), 'garbage'); + + expect(result).toBe(''); + }); + + it('preserves ending whitespace in file', () => { + const oldContent = spliceLines(TEXT, 99, 1, ['100 ']); + const newContent = spliceLines(oldContent, 99, 0, ['Lorem', 'Ipsum']); + const expected = ` + 99 ++Lorem ++Ipsum + 100 `; + + const result = createFileDiff( + createUpdatedFile(PATH, oldContent, newContent), + commitActionTypes.update, + ); + + expect(result).toContain(expected); + }); + + describe('with "create" action', () => { + const expectedHead = `diff --git "a/${PATH}" "b/${PATH}" +new file mode 100644`; + + const expectedChunkHead = lineCount => `--- /dev/null ++++ b/${PATH} +@@ -0,0 +1,${lineCount} @@`; + + it('with empty file, does not include diff body', () => { + const result = createFileDiff(createNewFile(PATH, ''), commitActionTypes.create); + + expect(result).toBe(`${expectedHead}\n`); + }); + + it('with single line, includes diff body', () => { + const result = createFileDiff(createNewFile(PATH, '\n'), commitActionTypes.create); + + expect(result).toBe(`${expectedHead} +${expectedChunkHead(1)} ++ +`); + }); + + it('without newline, includes no newline comment', () => { + const result = createFileDiff(createNewFile(PATH, 'Lorem ipsum'), commitActionTypes.create); + + expect(result).toBe(`${expectedHead} +${expectedChunkHead(1)} ++Lorem ipsum +\\ No newline at end of file +`); + }); + + it('with content, includes diff body', () => { + const content = `${TEXT}\n`; + const result = createFileDiff(createNewFile(PATH, content), commitActionTypes.create); + + expect(result).toBe(`${expectedHead} +${expectedChunkHead(TEXT_LINE_COUNT)} +${mapLines(TEXT, line => `+${line}`)} +`); + }); + }); + + describe('with "delete" action', () => { + const expectedHead = `diff --git "a/${PATH}" "b/${PATH}" +deleted file mode 100644`; + + const expectedChunkHead = lineCount => `--- a/${PATH} ++++ /dev/null +@@ -1,${lineCount} +0,0 @@`; + + it('with empty file, does not include diff body', () => { + const result = createFileDiff(createDeletedFile(PATH, ''), commitActionTypes.delete); + + expect(result).toBe(`${expectedHead}\n`); + }); + + it('with content, includes diff body', () => { + const content = `${TEXT}\n`; + const result = createFileDiff(createDeletedFile(PATH, content), commitActionTypes.delete); + + expect(result).toBe(`${expectedHead} +${expectedChunkHead(TEXT_LINE_COUNT)} +${mapLines(TEXT, line => `-${line}`)} +`); + }); + }); + + describe('with "update" action', () => { + it('includes diff body', () => { + const oldContent = `${TEXT}\n`; + const newContent = `${spliceLines(TEXT, 50, 3, ['Lorem'])}\n`; + + const result = createFileDiff( + createUpdatedFile(PATH, oldContent, newContent), + commitActionTypes.update, + ); + + expect(result).toBe(`diff --git "a/${PATH}" "b/${PATH}" +--- a/${PATH} ++++ b/${PATH} +@@ -47,11 +47,9 @@ + 47 + 48 + 49 + 50 +-51 +-52 +-53 ++Lorem + 54 + 55 + 56 + 57 +`); + }); + }); + + describe('with "move" action', () => { + it('returns rename head', () => { + const result = createFileDiff(createMovedFile(PATH, PATH_FOO), commitActionTypes.move); + + expect(result).toBe(`diff --git "a/${PATH_FOO}" "b/${PATH}" +rename from ${PATH_FOO} +rename to ${PATH} +`); + }); + }); +}); diff --git a/spec/frontend/ide/lib/diff/diff_spec.js b/spec/frontend/ide/lib/diff/diff_spec.js index d9b088e2c12..901f9e7cfd1 100644 --- a/spec/frontend/ide/lib/diff/diff_spec.js +++ b/spec/frontend/ide/lib/diff/diff_spec.js @@ -73,5 +73,13 @@ describe('Multi-file editor library diff calculator', () => { expect(diff.endLineNumber).toBe(1); }); + + it('disregards changes for EOL type changes', () => { + const text1 = 'line1\nline2\nline3\n'; + const text2 = 'line1\r\nline2\r\nline3\r\n'; + + expect(computeDiff(text1, text2)).toEqual([]); + expect(computeDiff(text2, text1)).toEqual([]); + }); }); }); diff --git a/spec/frontend/ide/lib/editor_options_spec.js b/spec/frontend/ide/lib/editor_options_spec.js deleted file mode 100644 index b07a583b7c8..00000000000 --- a/spec/frontend/ide/lib/editor_options_spec.js +++ /dev/null @@ -1,11 +0,0 @@ -import editorOptions from '~/ide/lib/editor_options'; - -describe('Multi-file editor library editor options', () => { - it('returns an array', () => { - expect(editorOptions).toEqual(expect.any(Array)); - }); - - it('contains readOnly option', () => { - expect(editorOptions[0].readOnly).toBeDefined(); - }); -}); diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js index 36d4c3c26ee..f5815771cdf 100644 --- a/spec/frontend/ide/lib/editor_spec.js +++ b/spec/frontend/ide/lib/editor_spec.js @@ -1,4 +1,9 @@ -import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; +import { + editor as monacoEditor, + languages as monacoLanguages, + Range, + Selection, +} from 'monaco-editor'; import Editor from '~/ide/lib/editor'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import { file } from '../helpers'; @@ -72,12 +77,13 @@ describe('Multi-file editor library', () => { expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, { ...defaultEditorOptions, + ignoreTrimWhitespace: false, quickSuggestions: false, occurrencesHighlight: false, renderSideBySide: false, - readOnly: true, - renderLineHighlight: 'all', - hideCursorInOverviewRuler: false, + readOnly: false, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, }); }); }); @@ -193,6 +199,38 @@ describe('Multi-file editor library', () => { }); }); + describe('replaceSelectedText', () => { + let model; + let editor; + + beforeEach(() => { + instance.createInstance(holder); + + model = instance.createModel({ + ...file(), + key: 'index.md', + path: 'index.md', + }); + + instance.attachModel(model); + + editor = instance.instance; + editor.getModel().setValue('foo bar baz'); + editor.setSelection(new Range(1, 5, 1, 8)); + + instance.replaceSelectedText('hello'); + }); + + it('replaces the text selected in editor with the one provided', () => { + expect(editor.getModel().getValue()).toBe('foo hello baz'); + }); + + it('sets cursor to end of the replaced string', () => { + const selection = editor.getSelection(); + expect(selection).toEqual(new Selection(1, 10, 1, 10)); + }); + }); + describe('dispose', () => { it('calls disposble dispose method', () => { jest.spyOn(instance.disposable, 'dispose'); diff --git a/spec/frontend/ide/lib/editorconfig/mock_data.js b/spec/frontend/ide/lib/editorconfig/mock_data.js new file mode 100644 index 00000000000..b21f4a5b735 --- /dev/null +++ b/spec/frontend/ide/lib/editorconfig/mock_data.js @@ -0,0 +1,146 @@ +export const exampleConfigs = [ + { + path: 'foo/bar/baz/.editorconfig', + content: ` +[*] +tab_width = 6 +indent_style = tab +`, + }, + { + path: 'foo/bar/.editorconfig', + content: ` +root = false + +[*] +indent_size = 5 +indent_style = space +trim_trailing_whitespace = true + +[*_spec.{js,py}] +end_of_line = crlf + `, + }, + { + path: 'foo/.editorconfig', + content: ` +[*] +tab_width = 4 +indent_style = tab + `, + }, + { + path: '.editorconfig', + content: ` +root = true + +[*] +indent_size = 3 +indent_style = space +end_of_line = lf +insert_final_newline = true + +[*.js] +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + +[*.txt] +end_of_line = crlf + `, + }, + { + path: 'foo/bar/root/.editorconfig', + content: ` +root = true + +[*] +tab_width = 1 +indent_style = tab + `, + }, +]; + +export const exampleFiles = [ + { + path: 'foo/bar/root/README.md', + rules: { + indent_style: 'tab', // foo/bar/root/.editorconfig + tab_width: '1', // foo/bar/root/.editorconfig + }, + monacoRules: { + insertSpaces: false, + tabSize: 1, + }, + }, + { + path: 'foo/bar/baz/my_spec.js', + rules: { + end_of_line: 'crlf', // foo/bar/.editorconfig (for _spec.js files) + indent_size: '5', // foo/bar/.editorconfig + indent_style: 'tab', // foo/bar/baz/.editorconfig + insert_final_newline: 'true', // .editorconfig + tab_width: '6', // foo/bar/baz/.editorconfig + trim_trailing_whitespace: 'true', // .editorconfig (for .js files) + }, + monacoRules: { + endOfLine: 1, + insertFinalNewline: true, + insertSpaces: false, + tabSize: 6, + trimTrailingWhitespace: true, + }, + }, + { + path: 'foo/my_file.js', + rules: { + end_of_line: 'lf', // .editorconfig + indent_size: '2', // .editorconfig (for .js files) + indent_style: 'tab', // foo/.editorconfig + insert_final_newline: 'true', // .editorconfig + tab_width: '4', // foo/.editorconfig + trim_trailing_whitespace: 'true', // .editorconfig (for .js files) + }, + monacoRules: { + endOfLine: 0, + insertFinalNewline: true, + insertSpaces: false, + tabSize: 4, + trimTrailingWhitespace: true, + }, + }, + { + path: 'foo/my_file.md', + rules: { + end_of_line: 'lf', // .editorconfig + indent_size: '3', // .editorconfig + indent_style: 'tab', // foo/.editorconfig + insert_final_newline: 'true', // .editorconfig + tab_width: '4', // foo/.editorconfig + }, + monacoRules: { + endOfLine: 0, + insertFinalNewline: true, + insertSpaces: false, + tabSize: 4, + }, + }, + { + path: 'foo/bar/my_file.txt', + rules: { + end_of_line: 'crlf', // .editorconfig (for .txt files) + indent_size: '5', // foo/bar/.editorconfig + indent_style: 'space', // foo/bar/.editorconfig + insert_final_newline: 'true', // .editorconfig + tab_width: '4', // foo/.editorconfig + trim_trailing_whitespace: 'true', // foo/bar/.editorconfig + }, + monacoRules: { + endOfLine: 1, + insertFinalNewline: true, + insertSpaces: true, + tabSize: 4, + trimTrailingWhitespace: true, + }, + }, +]; diff --git a/spec/frontend/ide/lib/editorconfig/parser_spec.js b/spec/frontend/ide/lib/editorconfig/parser_spec.js new file mode 100644 index 00000000000..f99410236e1 --- /dev/null +++ b/spec/frontend/ide/lib/editorconfig/parser_spec.js @@ -0,0 +1,18 @@ +import { getRulesWithTraversal } from '~/ide/lib/editorconfig/parser'; +import { exampleConfigs, exampleFiles } from './mock_data'; + +describe('~/ide/lib/editorconfig/parser', () => { + const getExampleConfigContent = path => + Promise.resolve(exampleConfigs.find(x => x.path === path)?.content); + + describe('getRulesWithTraversal', () => { + it.each(exampleFiles)( + 'traverses through all editorconfig files in parent directories (until root=true is hit) and finds rules for this file (case %#)', + ({ path, rules }) => { + return getRulesWithTraversal(path, getExampleConfigContent).then(result => { + expect(result).toEqual(rules); + }); + }, + ); + }); +}); diff --git a/spec/frontend/ide/lib/editorconfig/rules_mapper_spec.js b/spec/frontend/ide/lib/editorconfig/rules_mapper_spec.js new file mode 100644 index 00000000000..536b1409435 --- /dev/null +++ b/spec/frontend/ide/lib/editorconfig/rules_mapper_spec.js @@ -0,0 +1,43 @@ +import mapRulesToMonaco from '~/ide/lib/editorconfig/rules_mapper'; + +describe('mapRulesToMonaco', () => { + const multipleEntries = { + input: { indent_style: 'tab', indent_size: '4', insert_final_newline: 'true' }, + output: { insertSpaces: false, tabSize: 4, insertFinalNewline: true }, + }; + + // tab width takes precedence + const tabWidthAndIndent = { + input: { indent_style: 'tab', indent_size: '4', tab_width: '3' }, + output: { insertSpaces: false, tabSize: 3 }, + }; + + it.each` + rule | monacoOption + ${{ indent_style: 'tab' }} | ${{ insertSpaces: false }} + ${{ indent_style: 'space' }} | ${{ insertSpaces: true }} + ${{ indent_style: 'unset' }} | ${{}} + ${{ indent_size: '4' }} | ${{ tabSize: 4 }} + ${{ indent_size: '4.4' }} | ${{ tabSize: 4 }} + ${{ indent_size: '0' }} | ${{}} + ${{ indent_size: '-10' }} | ${{}} + ${{ indent_size: 'NaN' }} | ${{}} + ${{ tab_width: '4' }} | ${{ tabSize: 4 }} + ${{ tab_width: '5.4' }} | ${{ tabSize: 5 }} + ${{ tab_width: '-10' }} | ${{}} + ${{ trim_trailing_whitespace: 'true' }} | ${{ trimTrailingWhitespace: true }} + ${{ trim_trailing_whitespace: 'false' }} | ${{ trimTrailingWhitespace: false }} + ${{ trim_trailing_whitespace: 'unset' }} | ${{}} + ${{ end_of_line: 'lf' }} | ${{ endOfLine: 0 }} + ${{ end_of_line: 'crlf' }} | ${{ endOfLine: 1 }} + ${{ end_of_line: 'cr' }} | ${{}} + ${{ end_of_line: 'unset' }} | ${{}} + ${{ insert_final_newline: 'true' }} | ${{ insertFinalNewline: true }} + ${{ insert_final_newline: 'false' }} | ${{ insertFinalNewline: false }} + ${{ insert_final_newline: 'unset' }} | ${{}} + ${multipleEntries.input} | ${multipleEntries.output} + ${tabWidthAndIndent.input} | ${tabWidthAndIndent.output} + `('correctly maps editorconfig rule to monaco option: $rule', ({ rule, monacoOption }) => { + expect(mapRulesToMonaco(rule)).toEqual(monacoOption); + }); +}); diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js index 2b15aef6454..6974cdc4074 100644 --- a/spec/frontend/ide/lib/files_spec.js +++ b/spec/frontend/ide/lib/files_spec.js @@ -11,7 +11,6 @@ const createEntries = paths => { const createUrl = base => (type === 'tree' ? `${base}/` : base); const { name, parent } = splitParent(path); - const parentEntry = acc[parent]; const previewMode = viewerInformationForPath(name); acc[path] = { @@ -26,9 +25,6 @@ const createEntries = paths => { previewMode, binary: (previewMode && previewMode.binary) || false, parentPath: parent, - parentTreeUrl: parentEntry - ? parentEntry.url - : createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}`), }), tree: children.map(childName => expect.objectContaining({ name: childName })), }; diff --git a/spec/frontend/ide/lib/mirror_spec.js b/spec/frontend/ide/lib/mirror_spec.js new file mode 100644 index 00000000000..21bed5948f3 --- /dev/null +++ b/spec/frontend/ide/lib/mirror_spec.js @@ -0,0 +1,184 @@ +import createDiff from '~/ide/lib/create_diff'; +import { + canConnect, + createMirror, + SERVICE_NAME, + PROTOCOL, + MSG_CONNECTION_ERROR, + SERVICE_DELAY, +} from '~/ide/lib/mirror'; +import { getWebSocketUrl } from '~/lib/utils/url_utility'; + +jest.mock('~/ide/lib/create_diff', () => jest.fn()); + +const TEST_PATH = '/project/ide/proxy/path'; +const TEST_DIFF = { + patch: 'lorem ipsum', + toDelete: ['foo.md'], +}; +const TEST_ERROR = 'Something bad happened...'; +const TEST_SUCCESS_RESPONSE = { + data: JSON.stringify({ error: { code: 0 }, payload: { status_code: 200 } }), +}; +const TEST_ERROR_RESPONSE = { + data: JSON.stringify({ error: { code: 1, Message: TEST_ERROR }, payload: { status_code: 200 } }), +}; +const TEST_ERROR_PAYLOAD_RESPONSE = { + data: JSON.stringify({ + error: { code: 0 }, + payload: { status_code: 500, error_message: TEST_ERROR }, + }), +}; + +const buildUploadMessage = ({ toDelete, patch }) => + JSON.stringify({ + code: 'EVENT', + namespace: '/files', + event: 'PATCH', + payload: { diff: patch, delete_files: toDelete }, + }); + +describe('ide/lib/mirror', () => { + describe('canConnect', () => { + it('can connect if the session has the expected service', () => { + const result = canConnect({ services: ['test1', SERVICE_NAME, 'test2'] }); + + expect(result).toBe(true); + }); + + it('cannot connect if the session does not have the expected service', () => { + const result = canConnect({ services: ['test1', 'test2'] }); + + expect(result).toBe(false); + }); + }); + + describe('createMirror', () => { + const origWebSocket = global.WebSocket; + let mirror; + let mockWebSocket; + + beforeEach(() => { + mockWebSocket = { + close: jest.fn(), + send: jest.fn(), + }; + global.WebSocket = jest.fn().mockImplementation(() => mockWebSocket); + mirror = createMirror(); + }); + + afterEach(() => { + global.WebSocket = origWebSocket; + }); + + const waitForConnection = (delay = SERVICE_DELAY) => { + const wait = new Promise(resolve => { + setTimeout(resolve, 10); + }); + + jest.advanceTimersByTime(delay); + + return wait; + }; + const connectPass = () => waitForConnection().then(() => mockWebSocket.onopen()); + const connectFail = () => waitForConnection().then(() => mockWebSocket.onerror()); + const sendResponse = msg => { + mockWebSocket.onmessage(msg); + }; + + describe('connect', () => { + let connection; + + beforeEach(() => { + connection = mirror.connect(TEST_PATH); + }); + + it('waits before creating web socket', () => { + // ignore error when test suite terminates + connection.catch(() => {}); + + return waitForConnection(SERVICE_DELAY - 10).then(() => { + expect(global.WebSocket).not.toHaveBeenCalled(); + }); + }); + + it('is canceled when disconnected before finished waiting', () => { + mirror.disconnect(); + + return waitForConnection(SERVICE_DELAY).then(() => { + expect(global.WebSocket).not.toHaveBeenCalled(); + }); + }); + + describe('when connection is successful', () => { + beforeEach(connectPass); + + it('connects to service', () => { + const expectedPath = `${getWebSocketUrl(TEST_PATH)}?service=${SERVICE_NAME}`; + + return connection.then(() => { + expect(global.WebSocket).toHaveBeenCalledWith(expectedPath, [PROTOCOL]); + }); + }); + + it('disconnects when connected again', () => { + const result = connection + .then(() => { + // https://gitlab.com/gitlab-org/gitlab/issues/33024 + // eslint-disable-next-line promise/no-nesting + mirror.connect(TEST_PATH).catch(() => {}); + }) + .then(() => { + expect(mockWebSocket.close).toHaveBeenCalled(); + }); + + return result; + }); + }); + + describe('when connection fails', () => { + beforeEach(connectFail); + + it('rejects with error', () => { + return expect(connection).rejects.toEqual(new Error(MSG_CONNECTION_ERROR)); + }); + }); + }); + + describe('upload', () => { + let state; + + beforeEach(() => { + state = { changedFiles: [] }; + createDiff.mockReturnValue(TEST_DIFF); + + const connection = mirror.connect(TEST_PATH); + + return connectPass().then(() => connection); + }); + + it('creates a diff from the given state', () => { + const result = mirror.upload(state); + + sendResponse(TEST_SUCCESS_RESPONSE); + + return result.then(() => { + expect(createDiff).toHaveBeenCalledWith(state); + expect(mockWebSocket.send).toHaveBeenCalledWith(buildUploadMessage(TEST_DIFF)); + }); + }); + + it.each` + response | description + ${TEST_ERROR_RESPONSE} | ${'error in error'} + ${TEST_ERROR_PAYLOAD_RESPONSE} | ${'error in payload'} + `('rejects if response has $description', ({ response }) => { + const result = mirror.upload(state); + + sendResponse(response); + + return expect(result).rejects.toEqual({ message: TEST_ERROR }); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 43cb06f5d92..e2dc7626c67 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -5,7 +5,7 @@ import { createStore } from '~/ide/stores'; import * as actions from '~/ide/stores/actions/file'; import * as types from '~/ide/stores/mutation_types'; import service from '~/ide/services'; -import router from '~/ide/ide_router'; +import { createRouter } from '~/ide/ide_router'; import eventHub from '~/ide/eventhub'; import { file } from '../../helpers'; @@ -16,6 +16,7 @@ describe('IDE store file actions', () => { let mock; let originalGon; let store; + let router; beforeEach(() => { mock = new MockAdapter(axios); @@ -26,6 +27,7 @@ describe('IDE store file actions', () => { }; store = createStore(); + router = createRouter(store); jest.spyOn(store, 'commit'); jest.spyOn(store, 'dispatch'); @@ -44,7 +46,6 @@ describe('IDE store file actions', () => { localFile = file('testFile'); localFile.active = true; localFile.opened = true; - localFile.parentTreeUrl = 'parentTreeUrl'; store.state.openFiles.push(localFile); store.state.entries[localFile.path] = localFile; @@ -254,13 +255,8 @@ describe('IDE store file actions', () => { mock.onGet(`${RELATIVE_URL_ROOT}/test/test/-/7297abc/${localFile.path}`).replyOnce( 200, { - blame_path: 'blame_path', - commits_path: 'commits_path', - permalink: 'permalink', raw_path: 'raw_path', binary: false, - html: '123', - render_error: '', }, { 'page-title': 'testing getFileData', @@ -281,17 +277,6 @@ describe('IDE store file actions', () => { .catch(done.fail); }); - it('sets the file data', done => { - store - .dispatch('getFileData', { path: localFile.path }) - .then(() => { - expect(localFile.blamePath).toBe('blame_path'); - - done(); - }) - .catch(done.fail); - }); - it('sets document title with the branchId', done => { store .dispatch('getFileData', { path: localFile.path }) @@ -348,13 +333,8 @@ describe('IDE store file actions', () => { mock.onGet(`${RELATIVE_URL_ROOT}/test/test/-/7297abc/old-dull-file`).replyOnce( 200, { - blame_path: 'blame_path', - commits_path: 'commits_path', - permalink: 'permalink', raw_path: 'raw_path', binary: false, - html: '123', - render_error: '', }, { 'page-title': 'testing old-dull-file', @@ -587,20 +567,6 @@ describe('IDE store file actions', () => { }) .catch(done.fail); }); - - it('bursts unused seal', done => { - store - .dispatch('changeFileContent', { - path: tmpFile.path, - content: 'content', - }) - .then(() => { - expect(store.state.unusedSeal).toBe(false); - - done(); - }) - .catch(done.fail); - }); }); describe('with changed file', () => { diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js new file mode 100644 index 00000000000..cb4eebd97d9 --- /dev/null +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -0,0 +1,504 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import store from '~/ide/stores'; +import createFlash from '~/flash'; +import { + getMergeRequestData, + getMergeRequestChanges, + getMergeRequestVersions, + openMergeRequest, +} from '~/ide/stores/actions/merge_request'; +import service from '~/ide/services'; +import { leftSidebarViews, PERMISSION_READ_MR } from '~/ide/constants'; +import { resetStore } from '../../helpers'; + +const TEST_PROJECT = 'abcproject'; +const TEST_PROJECT_ID = 17; + +jest.mock('~/flash'); + +describe('IDE store merge request actions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + + store.state.projects[TEST_PROJECT] = { + id: TEST_PROJECT_ID, + mergeRequests: {}, + userPermissions: { + [PERMISSION_READ_MR]: true, + }, + }; + }); + + afterEach(() => { + mock.restore(); + resetStore(store); + }); + + describe('getMergeRequestsForBranch', () => { + describe('success', () => { + const mrData = { iid: 2, source_branch: 'bar' }; + const mockData = [mrData]; + + describe('base case', () => { + beforeEach(() => { + jest.spyOn(service, 'getProjectMergeRequests'); + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, mockData); + }); + + it('calls getProjectMergeRequests service method', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + .then(() => { + expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, { + source_branch: 'bar', + source_project_id: TEST_PROJECT_ID, + order_by: 'created_at', + per_page: 1, + }); + + done(); + }) + .catch(done.fail); + }); + + it('sets the "Merge Request" Object', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + .then(() => { + expect(store.state.projects.abcproject.mergeRequests).toEqual({ + '2': expect.objectContaining(mrData), + }); + done(); + }) + .catch(done.fail); + }); + + it('sets "Current Merge Request" object to the most recent MR', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + .then(() => { + expect(store.state.currentMergeRequestId).toEqual('2'); + done(); + }) + .catch(done.fail); + }); + + it('does nothing if user cannot read MRs', done => { + store.state.projects[TEST_PROJECT].userPermissions[PERMISSION_READ_MR] = false; + + store + .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + .then(() => { + expect(service.getProjectMergeRequests).not.toHaveBeenCalled(); + expect(store.state.currentMergeRequestId).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('no merge requests for branch available case', () => { + beforeEach(() => { + jest.spyOn(service, 'getProjectMergeRequests'); + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, []); + }); + + it('does not fail if there are no merge requests for current branch', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'foo' }) + .then(() => { + expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({}); + expect(store.state.currentMergeRequestId).toEqual(''); + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).networkError(); + }); + + it('flashes message, if error', done => { + store + .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' }) + .catch(() => { + expect(createFlash).toHaveBeenCalled(); + expect(createFlash.mock.calls[0][0]).toBe('Error fetching merge requests for bar'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('getMergeRequestData', () => { + describe('success', () => { + beforeEach(() => { + jest.spyOn(service, 'getProjectMergeRequestData'); + + mock + .onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/) + .reply(200, { title: 'mergerequest' }); + }); + + it('calls getProjectMergeRequestData service method', done => { + store + .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Object', done => { + store + .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 }) + .then(() => { + expect(store.state.currentMergeRequestId).toBe(1); + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe( + 'mergerequest', + ); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jest.fn(); + + getMergeRequestData( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: TEST_PROJECT, mergeRequestId: 1 }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, + }); + + done(); + }); + }); + }); + }); + + describe('getMergeRequestChanges', () => { + beforeEach(() => { + store.state.projects[TEST_PROJECT].mergeRequests['1'] = { changes: [] }; + }); + + describe('success', () => { + beforeEach(() => { + jest.spyOn(service, 'getProjectMergeRequestChanges'); + + mock + .onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/) + .reply(200, { title: 'mergerequest' }); + }); + + it('calls getProjectMergeRequestChanges service method', done => { + store + .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Changes Object', done => { + store + .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe( + 'mergerequest', + ); + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jest.fn(); + + getMergeRequestChanges( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: TEST_PROJECT, mergeRequestId: 1 }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request changes.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, + }); + + done(); + }); + }); + }); + }); + + describe('getMergeRequestVersions', () => { + beforeEach(() => { + store.state.projects[TEST_PROJECT].mergeRequests['1'] = { versions: [] }; + }); + + describe('success', () => { + beforeEach(() => { + mock + .onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/) + .reply(200, [{ id: 789 }]); + jest.spyOn(service, 'getProjectMergeRequestVersions'); + }); + + it('calls getProjectMergeRequestVersions service method', done => { + store + .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 }) + .then(() => { + expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1); + + done(); + }) + .catch(done.fail); + }); + + it('sets the Merge Request Versions Object', done => { + store + .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 }) + .then(() => { + expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1); + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + beforeEach(() => { + mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/).networkError(); + }); + + it('dispatches error action', done => { + const dispatch = jest.fn(); + + getMergeRequestVersions( + { + commit() {}, + dispatch, + state: store.state, + }, + { projectId: TEST_PROJECT, mergeRequestId: 1 }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading the merge request version data.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { + projectId: TEST_PROJECT, + mergeRequestId: 1, + force: false, + }, + }); + + done(); + }); + }); + }); + }); + + describe('openMergeRequest', () => { + const mr = { + projectId: TEST_PROJECT, + targetProjectId: 'defproject', + mergeRequestId: 2, + }; + let testMergeRequest; + let testMergeRequestChanges; + + const mockGetters = { findBranch: () => ({ commit: { id: 'abcd2322' } }) }; + + beforeEach(() => { + testMergeRequest = { + source_branch: 'abcbranch', + }; + testMergeRequestChanges = { + changes: [], + }; + store.state.entries = { + foo: { + type: 'blob', + }, + bar: { + type: 'blob', + }, + }; + + store.state.currentProjectId = 'test/test'; + store.state.currentBranchId = 'master'; + + store.state.projects['test/test'] = { + branches: { + master: { + commit: { + id: '7297abc', + }, + }, + abcbranch: { + commit: { + id: '29020fc', + }, + }, + }, + }; + + const originalDispatch = store.dispatch; + + jest.spyOn(store, 'dispatch').mockImplementation((type, payload) => { + switch (type) { + case 'getMergeRequestData': + return Promise.resolve(testMergeRequest); + case 'getMergeRequestChanges': + return Promise.resolve(testMergeRequestChanges); + case 'getFiles': + case 'getMergeRequestVersions': + case 'getBranchData': + case 'setFileMrChange': + return Promise.resolve(); + default: + return originalDispatch(type, payload); + } + }); + jest.spyOn(service, 'getFileData').mockImplementation(() => + Promise.resolve({ + headers: {}, + }), + ); + }); + + it('dispatches actions for merge request data', done => { + openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) + .then(() => { + expect(store.dispatch.mock.calls).toEqual([ + ['getMergeRequestData', mr], + ['setCurrentBranchId', testMergeRequest.source_branch], + [ + 'getBranchData', + { + projectId: mr.projectId, + branchId: testMergeRequest.source_branch, + }, + ], + [ + 'getFiles', + { + projectId: mr.projectId, + branchId: testMergeRequest.source_branch, + ref: 'abcd2322', + }, + ], + ['getMergeRequestVersions', mr], + ['getMergeRequestChanges', mr], + ]); + }) + .then(done) + .catch(done.fail); + }); + + it('updates activity bar view and gets file data, if changes are found', done => { + store.state.entries.foo = { + url: 'test', + type: 'blob', + }; + store.state.entries.bar = { + url: 'test', + type: 'blob', + }; + + testMergeRequestChanges.changes = [ + { new_path: 'foo', path: 'foo' }, + { new_path: 'bar', path: 'bar' }, + ]; + + openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr) + .then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'updateActivityBarView', + leftSidebarViews.review.name, + ); + + testMergeRequestChanges.changes.forEach((change, i) => { + expect(store.dispatch).toHaveBeenCalledWith('setFileMrChange', { + file: store.state.entries[change.new_path], + mrChange: change, + }); + + expect(store.dispatch).toHaveBeenCalledWith('getFileData', { + path: change.new_path, + makeFileActive: i === 0, + openFile: true, + }); + }); + + expect(store.state.openFiles.length).toBe(testMergeRequestChanges.changes.length); + }) + .then(done) + .catch(done.fail); + }); + + it('flashes message, if error', done => { + store.dispatch.mockRejectedValue(); + + openMergeRequest(store, mr) + .catch(() => { + expect(createFlash).toHaveBeenCalledWith(expect.any(String)); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js new file mode 100644 index 00000000000..64024c12903 --- /dev/null +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -0,0 +1,397 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import { createStore } from '~/ide/stores'; +import { + refreshLastCommitData, + showBranchNotFoundError, + createNewBranchFromDefault, + loadEmptyBranch, + openBranch, + loadFile, + loadBranch, +} from '~/ide/stores/actions'; +import service from '~/ide/services'; +import api from '~/api'; +import testAction from 'helpers/vuex_action_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; + +const TEST_PROJECT_ID = 'abc/def'; + +describe('IDE store project actions', () => { + let mock; + let store; + + beforeEach(() => { + store = createStore(); + mock = new MockAdapter(axios); + + store.state.projects[TEST_PROJECT_ID] = { + branches: {}, + }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('refreshLastCommitData', () => { + beforeEach(() => { + store.state.currentProjectId = 'abc/def'; + store.state.currentBranchId = 'master'; + store.state.projects['abc/def'] = { + id: 4, + branches: { + master: { + commit: null, + }, + }, + }; + jest.spyOn(service, 'getBranchData').mockResolvedValue({ + data: { + commit: { id: '123' }, + }, + }); + }); + + it('calls the service', done => { + store + .dispatch('refreshLastCommitData', { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + }) + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('commits getBranchData', done => { + testAction( + refreshLastCommitData, + { + projectId: store.state.currentProjectId, + branchId: store.state.currentBranchId, + }, + store.state, + // mutations + [ + { + type: 'SET_BRANCH_COMMIT', + payload: { + projectId: TEST_PROJECT_ID, + branchId: 'master', + commit: { id: '123' }, + }, + }, + ], + // action + [], + done, + ); + }); + }); + + describe('showBranchNotFoundError', () => { + it('dispatches setErrorMessage', done => { + testAction( + showBranchNotFoundError, + 'master', + null, + [], + [ + { + type: 'setErrorMessage', + payload: { + text: "Branch <strong>master</strong> was not found in this project's repository.", + action: expect.any(Function), + actionText: 'Create branch', + actionPayload: 'master', + }, + }, + ], + done, + ); + }); + }); + + describe('createNewBranchFromDefault', () => { + useMockLocationHelper(); + + beforeEach(() => { + jest.spyOn(api, 'createBranch').mockResolvedValue(); + }); + + it('calls API', done => { + createNewBranchFromDefault( + { + state: { + currentProjectId: 'project-path', + }, + getters: { + currentProject: { + default_branch: 'master', + }, + }, + dispatch() {}, + }, + 'new-branch-name', + ) + .then(() => { + expect(api.createBranch).toHaveBeenCalledWith('project-path', { + ref: 'master', + branch: 'new-branch-name', + }); + }) + .then(done) + .catch(done.fail); + }); + + it('clears error message', done => { + const dispatchSpy = jest.fn().mockName('dispatch'); + + createNewBranchFromDefault( + { + state: { + currentProjectId: 'project-path', + }, + getters: { + currentProject: { + default_branch: 'master', + }, + }, + dispatch: dispatchSpy, + }, + 'new-branch-name', + ) + .then(() => { + expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null); + }) + .then(done) + .catch(done.fail); + }); + + it('reloads window', done => { + createNewBranchFromDefault( + { + state: { + currentProjectId: 'project-path', + }, + getters: { + currentProject: { + default_branch: 'master', + }, + }, + dispatch() {}, + }, + 'new-branch-name', + ) + .then(() => { + expect(window.location.reload).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('loadEmptyBranch', () => { + it('creates a blank tree and sets loading state to false', done => { + testAction( + loadEmptyBranch, + { projectId: TEST_PROJECT_ID, branchId: 'master' }, + store.state, + [ + { type: 'CREATE_TREE', payload: { treePath: `${TEST_PROJECT_ID}/master` } }, + { + type: 'TOGGLE_LOADING', + payload: { entry: store.state.trees[`${TEST_PROJECT_ID}/master`], forceValue: false }, + }, + ], + expect.any(Object), + done, + ); + }); + + it('does nothing, if tree already exists', done => { + const trees = { [`${TEST_PROJECT_ID}/master`]: [] }; + + testAction( + loadEmptyBranch, + { projectId: TEST_PROJECT_ID, branchId: 'master' }, + { trees }, + [], + [], + done, + ); + }); + }); + + describe('loadFile', () => { + beforeEach(() => { + Object.assign(store.state, { + entries: { + foo: { pending: false }, + 'foo/bar-pending': { pending: true }, + 'foo/bar': { pending: false }, + }, + }); + jest.spyOn(store, 'dispatch').mockImplementation(); + }); + + it('does nothing, if basePath is not given', () => { + loadFile(store, { basePath: undefined }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('handles tree entry action, if basePath is given and the entry is not pending', () => { + loadFile(store, { basePath: 'foo/bar/' }); + + expect(store.dispatch).toHaveBeenCalledWith( + 'handleTreeEntryAction', + store.state.entries['foo/bar'], + ); + }); + + it('does not handle tree entry action, if entry is pending', () => { + loadFile(store, { basePath: 'foo/bar-pending/' }); + + expect(store.dispatch).not.toHaveBeenCalledWith('handleTreeEntryAction', expect.anything()); + }); + + it('creates a new temp file supplied via URL if the file does not exist yet', () => { + loadFile(store, { basePath: 'not-existent.md' }); + + expect(store.dispatch.mock.calls).toHaveLength(1); + + expect(store.dispatch).not.toHaveBeenCalledWith('handleTreeEntryAction', expect.anything()); + + expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { + name: 'not-existent.md', + type: 'blob', + }); + }); + }); + + describe('loadBranch', () => { + const projectId = TEST_PROJECT_ID; + const branchId = '123-lorem'; + const ref = 'abcd2322'; + + it('when empty repo, loads empty branch', done => { + const mockGetters = { emptyRepo: true }; + + testAction( + loadBranch, + { projectId, branchId }, + { ...store.state, ...mockGetters }, + [], + [{ type: 'loadEmptyBranch', payload: { projectId, branchId } }], + done, + ); + }); + + it('when branch already exists, does nothing', done => { + store.state.projects[projectId].branches[branchId] = {}; + + testAction(loadBranch, { projectId, branchId }, store.state, [], [], done); + }); + + it('fetches branch data', done => { + const mockGetters = { findBranch: () => ({ commit: { id: ref } }) }; + jest.spyOn(store, 'dispatch').mockResolvedValue(); + + loadBranch( + { getters: mockGetters, state: store.state, dispatch: store.dispatch }, + { projectId, branchId }, + ) + .then(() => { + expect(store.dispatch.mock.calls).toEqual([ + ['getBranchData', { projectId, branchId }], + ['getMergeRequestsForBranch', { projectId, branchId }], + ['getFiles', { projectId, branchId, ref }], + ]); + }) + .then(done) + .catch(done.fail); + }); + + it('shows an error if branch can not be fetched', done => { + jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject()); + + loadBranch(store, { projectId, branchId }) + .then(done.fail) + .catch(() => { + expect(store.dispatch.mock.calls).toEqual([ + ['getBranchData', { projectId, branchId }], + ['showBranchNotFoundError', branchId], + ]); + done(); + }); + }); + }); + + describe('openBranch', () => { + const projectId = TEST_PROJECT_ID; + const branchId = '123-lorem'; + + const branch = { + projectId, + branchId, + }; + + beforeEach(() => { + Object.assign(store.state, { + entries: { + foo: { pending: false }, + 'foo/bar-pending': { pending: true }, + 'foo/bar': { pending: false }, + }, + }); + }); + + describe('existing branch', () => { + beforeEach(() => { + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + it('dispatches branch actions', done => { + openBranch(store, branch) + .then(() => { + expect(store.dispatch.mock.calls).toEqual([ + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ['loadFile', { basePath: undefined }], + ]); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('non-existent branch', () => { + beforeEach(() => { + jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject()); + }); + + it('dispatches correct branch actions', done => { + openBranch(store, branch) + .then(val => { + expect(store.dispatch.mock.calls).toEqual([ + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ]); + + expect(val).toEqual( + new Error( + `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`, + ), + ); + }) + .then(done) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js new file mode 100644 index 00000000000..44e2fcab436 --- /dev/null +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -0,0 +1,218 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { showTreeEntry, getFiles, setDirectoryData } from '~/ide/stores/actions/tree'; +import * as types from '~/ide/stores/mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import { createStore } from '~/ide/stores'; +import service from '~/ide/services'; +import { createRouter } from '~/ide/ide_router'; +import { file, createEntriesFromPaths } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + let projectTree; + let mock; + let store; + let router; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + branchId: 'master', + ref: '12345678', + }; + + beforeEach(() => { + store = createStore(); + router = createRouter(store); + jest.spyOn(router, 'push').mockImplementation(); + + mock = new MockAdapter(axios); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + path_with_namespace: 'foo/abcproject', + }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('getFiles', () => { + describe('success', () => { + beforeEach(() => { + jest.spyOn(service, 'getFiles'); + + mock + .onGet(/(.*)/) + .replyOnce(200, [ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]); + }); + + it('calls service getFiles', () => { + return ( + store + .dispatch('getFiles', basicCallParameters) + // getFiles actions calls lodash.defer + .then(() => jest.runOnlyPendingTimers()) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('foo/abcproject', '12345678'); + }) + ); + }); + + it('adds data into tree', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + // The populating of the tree is deferred for performance reasons. + // See this merge request for details: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/25700 + jest.advanceTimersByTime(1); + }) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('error', () => { + it('dispatches error action', done => { + const dispatch = jest.fn(); + + store.state.projects = { + 'abc/def': { + web_url: `${gl.TEST_HOST}/files`, + branches: { + 'master-testing': { + commit: { + id: '12345', + }, + }, + }, + }, + }; + const getters = { + findBranch: () => store.state.projects['abc/def'].branches['master-testing'], + }; + + mock.onGet(/(.*)/).replyOnce(500); + + getFiles( + { + commit() {}, + dispatch, + state: store.state, + getters, + }, + { + projectId: 'abc/def', + branchId: 'master-testing', + }, + ) + .then(done.fail) + .catch(() => { + expect(dispatch).toHaveBeenCalledWith('setErrorMessage', { + text: 'An error occurred while loading all the files.', + action: expect.any(Function), + actionText: 'Please try again', + actionPayload: { projectId: 'abc/def', branchId: 'master-testing' }, + }); + done(); + }); + }); + }); + }); + + describe('toggleTreeOpen', () => { + let tree; + + beforeEach(() => { + tree = file('testing', '1', 'tree'); + store.state.entries[tree.path] = tree; + }); + + it('toggles the tree open', done => { + store + .dispatch('toggleTreeOpen', tree.path) + .then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('showTreeEntry', () => { + beforeEach(() => { + const paths = [ + 'grandparent', + 'ancestor', + 'grandparent/parent', + 'grandparent/aunt', + 'grandparent/parent/child.txt', + 'grandparent/aunt/cousing.txt', + ]; + + Object.assign(store.state.entries, createEntriesFromPaths(paths)); + }); + + it('opens the parents', done => { + testAction( + showTreeEntry, + 'grandparent/parent/child.txt', + store.state, + [{ type: types.SET_TREE_OPEN, payload: 'grandparent/parent' }], + [{ type: 'showTreeEntry', payload: 'grandparent/parent' }], + done, + ); + }); + }); + + describe('setDirectoryData', () => { + it('sets tree correctly if there are no opened files yet', done => { + const treeFile = file({ name: 'README.md' }); + store.state.trees['abcproject/master'] = {}; + + testAction( + setDirectoryData, + { projectId: 'abcproject', branchId: 'master', treeList: [treeFile] }, + store.state, + [ + { + type: types.SET_DIRECTORY_DATA, + payload: { + treePath: 'abcproject/master', + data: [treeFile], + }, + }, + { + type: types.TOGGLE_LOADING, + payload: { + entry: {}, + forceValue: false, + }, + }, + ], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js new file mode 100644 index 00000000000..f77dbd80025 --- /dev/null +++ b/spec/frontend/ide/stores/actions_spec.js @@ -0,0 +1,1062 @@ +import MockAdapter from 'axios-mock-adapter'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { createStore } from '~/ide/stores'; +import { createRouter } from '~/ide/ide_router'; +import { + stageAllChanges, + unstageAllChanges, + toggleFileFinder, + setCurrentBranchId, + setEmptyStateSvgs, + updateActivityBarView, + updateTempFlagForEntry, + setErrorMessage, + deleteEntry, + renameEntry, + getBranchData, + createTempEntry, + discardAllChanges, +} from '~/ide/stores/actions'; +import axios from '~/lib/utils/axios_utils'; +import * as types from '~/ide/stores/mutation_types'; +import { file } from '../helpers'; +import testAction from '../../helpers/vuex_action_helper'; +import eventHub from '~/ide/eventhub'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + joinPaths: jest.requireActual('~/lib/utils/url_utility').joinPaths, +})); + +describe('Multi-file store actions', () => { + let store; + let router; + + beforeEach(() => { + store = createStore(); + router = createRouter(store); + + jest.spyOn(store, 'commit'); + jest.spyOn(store, 'dispatch'); + jest.spyOn(router, 'push').mockImplementation(); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', done => { + store + .dispatch('redirectToUrl', 'test') + .then(() => { + expect(visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', done => { + store + .dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + const paths = ['to_discard', 'another_one_to_discard']; + + beforeEach(() => { + paths.forEach(path => { + const f = file(path); + f.changed = true; + + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); + }); + + it('discards all changes in file', () => { + const expectedCalls = paths.map(path => ['restoreOriginalFile', path]); + + discardAllChanges(store); + + expect(store.dispatch.mock.calls).toEqual(expect.arrayContaining(expectedCalls)); + }); + + it('removes all files from changedFiles state', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(2); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('createTempEntry', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'mybranch'; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('tree', () => { + it('creates temp tree', done => { + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'test', + type: 'tree', + }) + .then(() => { + const entry = store.state.entries.test; + + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates new folder inside another tree', done => { + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing/test', + type: 'tree', + }) + .then(() => { + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('does not create new tree if already exists', done => { + const tree = { + type: 'tree', + path: 'testing', + tempFile: false, + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing', + type: 'tree', + }) + .then(() => { + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('blob', () => { + it('creates temp file', done => { + const name = 'test'; + + store + .dispatch('createTempEntry', { + name, + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + const f = store.state.entries[name]; + + expect(f.tempFile).toBeTruthy(); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to open files', done => { + const name = 'test'; + + store + .dispatch('createTempEntry', { + name, + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + const f = store.state.entries[name]; + + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to staged files', done => { + const name = 'test'; + + store + .dispatch('createTempEntry', { + name, + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]); + + done(); + }) + .catch(done.fail); + }); + + it('sets tmp file as active', () => { + createTempEntry(store, { name: 'test', branchId: 'mybranch', type: 'blob' }); + + expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test'); + }); + + it('creates flash message if file already exists', done => { + const f = file('test', '1', 'blob'); + store.state.trees['abcproject/mybranch'].tree = [f]; + store.state.entries[f.path] = f; + + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual( + `The name "${f.name}" is already taken in this directory.`, + ); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('scrollToTab', () => { + it('focuses the current active element', done => { + document.body.innerHTML += + '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>'; + const el = document.querySelector('.repo-tab'); + jest.spyOn(el, 'focus').mockImplementation(); + + store + .dispatch('scrollToTab') + .then(() => { + setImmediate(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); + + describe('stage/unstageAllChanges', () => { + let file1; + let file2; + + beforeEach(() => { + file1 = { ...file('test'), content: 'changed test', raw: 'test' }; + file2 = { ...file('test2'), content: 'changed test2', raw: 'test2' }; + + store.state.openFiles = [file1]; + store.state.changedFiles = [file1]; + store.state.stagedFiles = [{ ...file2, content: 'staged test' }]; + + store.state.entries = { + [file1.path]: { ...file1 }, + [file2.path]: { ...file2 }, + }; + }); + + describe('stageAllChanges', () => { + it('adds all files from changedFiles to stagedFiles', () => { + stageAllChanges(store); + + expect(store.commit.mock.calls).toEqual( + expect.arrayContaining([ + [types.SET_LAST_COMMIT_MSG, ''], + [types.STAGE_CHANGE, expect.objectContaining({ path: file1.path })], + ]), + ); + }); + + it('opens pending tab if a change exists in that file', () => { + stageAllChanges(store); + + expect(store.dispatch.mock.calls).toEqual([ + [ + 'openPendingTab', + { file: { ...file1, staged: true, changed: true }, keyPrefix: 'staged' }, + ], + ]); + }); + + it('does not open pending tab if no change exists in that file', () => { + store.state.entries[file1.path].content = 'test'; + store.state.stagedFiles = [file1]; + store.state.changedFiles = [store.state.entries[file1.path]]; + + stageAllChanges(store); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('unstageAllChanges', () => { + it('removes all files from stagedFiles after unstaging', () => { + unstageAllChanges(store); + + expect(store.commit.mock.calls).toEqual( + expect.arrayContaining([ + [types.UNSTAGE_CHANGE, expect.objectContaining({ path: file2.path })], + ]), + ); + }); + + it('opens pending tab if a change exists in that file', () => { + unstageAllChanges(store); + + expect(store.dispatch.mock.calls).toEqual([ + ['openPendingTab', { file: file1, keyPrefix: 'unstaged' }], + ]); + }); + + it('does not open pending tab if no change exists in that file', () => { + store.state.entries[file1.path].content = 'test'; + store.state.stagedFiles = [file1]; + store.state.changedFiles = [store.state.entries[file1.path]]; + + unstageAllChanges(store); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('updateViewer', () => { + it('updates viewer state', done => { + store + .dispatch('updateViewer', 'diff') + .then(() => { + expect(store.state.viewer).toBe('diff'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateActivityBarView', () => { + it('commits UPDATE_ACTIVITY_BAR_VIEW', done => { + testAction( + updateActivityBarView, + 'test', + {}, + [{ type: 'UPDATE_ACTIVITY_BAR_VIEW', payload: 'test' }], + [], + done, + ); + }); + }); + + describe('setEmptyStateSvgs', () => { + it('commits setEmptyStateSvgs', done => { + testAction( + setEmptyStateSvgs, + 'svg', + {}, + [{ type: 'SET_EMPTY_STATE_SVGS', payload: 'svg' }], + [], + done, + ); + }); + }); + + describe('updateTempFlagForEntry', () => { + it('commits UPDATE_TEMP_FLAG', done => { + const f = { + ...file(), + path: 'test', + tempFile: true, + }; + store.state.entries[f.path] = f; + + testAction( + updateTempFlagForEntry, + { file: f, tempFile: false }, + store.state, + [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], + [], + done, + ); + }); + + it('commits UPDATE_TEMP_FLAG and dispatches for parent', done => { + const parent = { + ...file(), + path: 'testing', + }; + const f = { + ...file(), + path: 'test', + parentPath: 'testing', + }; + store.state.entries[parent.path] = parent; + store.state.entries[f.path] = f; + + testAction( + updateTempFlagForEntry, + { file: f, tempFile: false }, + store.state, + [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], + [{ type: 'updateTempFlagForEntry', payload: { file: parent, tempFile: false } }], + done, + ); + }); + + it('does not dispatch for parent, if parent does not exist', done => { + const f = { + ...file(), + path: 'test', + parentPath: 'testing', + }; + store.state.entries[f.path] = f; + + testAction( + updateTempFlagForEntry, + { file: f, tempFile: false }, + store.state, + [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], + [], + done, + ); + }); + }); + + describe('setCurrentBranchId', () => { + it('commits setCurrentBranchId', done => { + testAction( + setCurrentBranchId, + 'branchId', + {}, + [{ type: 'SET_CURRENT_BRANCH', payload: 'branchId' }], + [], + done, + ); + }); + }); + + describe('toggleFileFinder', () => { + it('commits TOGGLE_FILE_FINDER', done => { + testAction( + toggleFileFinder, + true, + null, + [{ type: 'TOGGLE_FILE_FINDER', payload: true }], + [], + done, + ); + }); + }); + + describe('setErrorMessage', () => { + it('commis error messsage', done => { + testAction( + setErrorMessage, + 'error', + null, + [{ type: types.SET_ERROR_MESSAGE, payload: 'error' }], + [], + done, + ); + }); + }); + + describe('deleteEntry', () => { + it('commits entry deletion', done => { + store.state.entries.path = 'testing'; + + testAction( + deleteEntry, + 'path', + store.state, + [{ type: types.DELETE_ENTRY, payload: 'path' }], + [{ type: 'stageChange', payload: 'path' }, { type: 'triggerFilesChange' }], + done, + ); + }); + + it('does not delete a folder after it is emptied', done => { + const testFolder = { + type: 'tree', + tree: [], + }; + const testEntry = { + path: 'testFolder/entry-to-delete', + parentPath: 'testFolder', + opened: false, + tree: [], + }; + testFolder.tree.push(testEntry); + store.state.entries = { + testFolder, + 'testFolder/entry-to-delete': testEntry, + }; + + testAction( + deleteEntry, + 'testFolder/entry-to-delete', + store.state, + [{ type: types.DELETE_ENTRY, payload: 'testFolder/entry-to-delete' }], + [ + { type: 'stageChange', payload: 'testFolder/entry-to-delete' }, + { type: 'triggerFilesChange' }, + ], + done, + ); + }); + + describe('when renamed', () => { + let testEntry; + + beforeEach(() => { + testEntry = { + path: 'test', + name: 'test', + prevPath: 'test_old', + prevName: 'test_old', + prevParentPath: '', + }; + + store.state.entries = { test: testEntry }; + }); + + describe('and previous does not exist', () => { + it('reverts the rename before deleting', done => { + testAction( + deleteEntry, + testEntry.path, + store.state, + [], + [ + { + type: 'renameEntry', + payload: { + path: testEntry.path, + name: testEntry.prevName, + parentPath: testEntry.prevParentPath, + }, + }, + { + type: 'deleteEntry', + payload: testEntry.prevPath, + }, + ], + done, + ); + }); + }); + + describe('and previous exists', () => { + beforeEach(() => { + const oldEntry = { + path: testEntry.prevPath, + name: testEntry.prevName, + }; + + store.state.entries[oldEntry.path] = oldEntry; + }); + + it('does not revert rename before deleting', done => { + testAction( + deleteEntry, + testEntry.path, + store.state, + [{ type: types.DELETE_ENTRY, payload: testEntry.path }], + [{ type: 'stageChange', payload: testEntry.path }, { type: 'triggerFilesChange' }], + done, + ); + }); + + it('when previous is deleted, it reverts rename before deleting', done => { + store.state.entries[testEntry.prevPath].deleted = true; + + testAction( + deleteEntry, + testEntry.path, + store.state, + [], + [ + { + type: 'renameEntry', + payload: { + path: testEntry.path, + name: testEntry.prevName, + parentPath: testEntry.prevParentPath, + }, + }, + { + type: 'deleteEntry', + payload: testEntry.prevPath, + }, + ], + done, + ); + }); + }); + }); + }); + + describe('renameEntry', () => { + describe('purging of file model cache', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + }); + + it('does not purge model cache for temporary entries that got renamed', done => { + Object.assign(store.state.entries, { + test: { + ...file('test'), + key: 'foo-key', + type: 'blob', + tempFile: true, + }, + }); + + store + .dispatch('renameEntry', { + path: 'test', + name: 'new', + }) + .then(() => { + expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar'); + }) + .then(done) + .catch(done.fail); + }); + + it('purges model cache for renamed entry', done => { + Object.assign(store.state.entries, { + test: { + ...file('test'), + key: 'foo-key', + type: 'blob', + tempFile: false, + }, + }); + + store + .dispatch('renameEntry', { + path: 'test', + name: 'new', + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('single entry', () => { + let origEntry; + let renamedEntry; + + beforeEach(() => { + // Need to insert both because `testAction` doesn't actually call the mutation + origEntry = file('orig', 'orig', 'blob'); + renamedEntry = { + ...file('renamed', 'renamed', 'blob'), + prevKey: origEntry.key, + prevName: origEntry.name, + prevPath: origEntry.path, + }; + + Object.assign(store.state.entries, { + orig: origEntry, + renamed: renamedEntry, + }); + }); + + it('by default renames an entry and stages it', () => { + const dispatch = jest.fn(); + const commit = jest.fn(); + + renameEntry( + { dispatch, commit, state: store.state, getters: store.getters }, + { path: 'orig', name: 'renamed' }, + ); + + expect(commit.mock.calls).toEqual([ + [types.RENAME_ENTRY, { path: 'orig', name: 'renamed', parentPath: undefined }], + [types.STAGE_CHANGE, expect.objectContaining({ path: 'renamed' })], + ]); + }); + + it('if not changed, completely unstages and discards entry if renamed to original', done => { + testAction( + renameEntry, + { path: 'renamed', name: 'orig' }, + store.state, + [ + { + type: types.RENAME_ENTRY, + payload: { + path: 'renamed', + name: 'orig', + parentPath: undefined, + }, + }, + { + type: types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, + payload: origEntry, + }, + ], + [{ type: 'triggerFilesChange' }], + done, + ); + }); + + it('if already in changed, does not add to change', done => { + store.state.changedFiles.push(renamedEntry); + + testAction( + renameEntry, + { path: 'orig', name: 'renamed' }, + store.state, + [expect.objectContaining({ type: types.RENAME_ENTRY })], + [{ type: 'triggerFilesChange' }], + done, + ); + }); + + it('routes to the renamed file if the original file has been opened', done => { + Object.assign(store.state.entries.orig, { + opened: true, + url: '/foo-bar.md', + }); + + store + .dispatch('renameEntry', { + path: 'orig', + name: 'renamed', + }) + .then(() => { + expect(router.push.mock.calls).toHaveLength(1); + expect(router.push).toHaveBeenCalledWith(`/project/foo-bar.md`); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('folder', () => { + let folder; + let file1; + let file2; + + beforeEach(() => { + folder = file('folder', 'folder', 'tree'); + file1 = file('file-1', 'file-1', 'blob', folder); + file2 = file('file-2', 'file-2', 'blob', folder); + + folder.tree = [file1, file2]; + + Object.assign(store.state.entries, { + [folder.path]: folder, + [file1.path]: file1, + [file2.path]: file2, + }); + }); + + it('updates entries in a folder correctly, when folder is renamed', done => { + store + .dispatch('renameEntry', { + path: 'folder', + name: 'new-folder', + }) + .then(() => { + const keys = Object.keys(store.state.entries); + + expect(keys.length).toBe(3); + expect(keys.indexOf('new-folder')).toBe(0); + expect(keys.indexOf('new-folder/file-1')).toBe(1); + expect(keys.indexOf('new-folder/file-2')).toBe(2); + }) + .then(done) + .catch(done.fail); + }); + + it('discards renaming of an entry if the root folder is renamed back to a previous name', done => { + const rootFolder = file('old-folder', 'old-folder', 'tree'); + const testEntry = file('test', 'test', 'blob', rootFolder); + + Object.assign(store.state, { + entries: { + 'old-folder': { + ...rootFolder, + tree: [testEntry], + }, + 'old-folder/test': testEntry, + }, + }); + + store + .dispatch('renameEntry', { + path: 'old-folder', + name: 'new-folder', + }) + .then(() => { + const { entries } = store.state; + + expect(Object.keys(entries).length).toBe(2); + expect(entries['old-folder']).toBeUndefined(); + expect(entries['old-folder/test']).toBeUndefined(); + + expect(entries['new-folder']).toBeDefined(); + expect(entries['new-folder/test']).toEqual( + expect.objectContaining({ + path: 'new-folder/test', + name: 'test', + prevPath: 'old-folder/test', + prevName: 'test', + }), + ); + }) + .then(() => + store.dispatch('renameEntry', { + path: 'new-folder', + name: 'old-folder', + }), + ) + .then(() => { + const { entries } = store.state; + + expect(Object.keys(entries).length).toBe(2); + expect(entries['new-folder']).toBeUndefined(); + expect(entries['new-folder/test']).toBeUndefined(); + + expect(entries['old-folder']).toBeDefined(); + expect(entries['old-folder/test']).toEqual( + expect.objectContaining({ + path: 'old-folder/test', + name: 'test', + prevPath: undefined, + prevName: undefined, + }), + ); + }) + .then(done) + .catch(done.fail); + }); + + describe('with file in directory', () => { + const parentPath = 'original-dir'; + const newParentPath = 'new-dir'; + const fileName = 'test.md'; + const filePath = `${parentPath}/${fileName}`; + + let rootDir; + + beforeEach(() => { + const parentEntry = file(parentPath, parentPath, 'tree'); + const fileEntry = file(filePath, filePath, 'blob', parentEntry); + rootDir = { + tree: [], + }; + + Object.assign(store.state, { + entries: { + [parentPath]: { + ...parentEntry, + tree: [fileEntry], + }, + [filePath]: fileEntry, + }, + trees: { + '/': rootDir, + }, + }); + }); + + it('creates new directory', done => { + expect(store.state.entries[newParentPath]).toBeUndefined(); + + store + .dispatch('renameEntry', { path: filePath, name: fileName, parentPath: newParentPath }) + .then(() => { + expect(store.state.entries[newParentPath]).toEqual( + expect.objectContaining({ + path: newParentPath, + type: 'tree', + tree: expect.arrayContaining([ + store.state.entries[`${newParentPath}/${fileName}`], + ]), + }), + ); + }) + .then(done) + .catch(done.fail); + }); + + describe('when new directory exists', () => { + let newDir; + + beforeEach(() => { + newDir = file(newParentPath, newParentPath, 'tree'); + + store.state.entries[newDir.path] = newDir; + rootDir.tree.push(newDir); + }); + + it('inserts in new directory', done => { + expect(newDir.tree).toEqual([]); + + store + .dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }) + .then(() => { + expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]); + }) + .then(done) + .catch(done.fail); + }); + + it('when new directory is deleted, it undeletes it', done => { + store.dispatch('deleteEntry', newParentPath); + + expect(store.state.entries[newParentPath].deleted).toBe(true); + expect(rootDir.tree.some(x => x.path === newParentPath)).toBe(false); + + store + .dispatch('renameEntry', { + path: filePath, + name: fileName, + parentPath: newParentPath, + }) + .then(() => { + expect(store.state.entries[newParentPath].deleted).toBe(false); + expect(rootDir.tree.some(x => x.path === newParentPath)).toBe(true); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + }); + }); + + describe('getBranchData', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('error', () => { + let dispatch; + let callParams; + + beforeEach(() => { + callParams = [ + { + commit() {}, + state: store.state, + }, + { + projectId: 'abc/def', + branchId: 'master-testing', + }, + ]; + dispatch = jest.fn(); + document.body.innerHTML += '<div class="flash-container"></div>'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + it('passes the error further unchanged without dispatching any action when response is 404', done => { + mock.onGet(/(.*)/).replyOnce(404); + + getBranchData(...callParams) + .then(done.fail) + .catch(e => { + expect(dispatch.mock.calls).toHaveLength(0); + expect(e.response.status).toEqual(404); + expect(document.querySelector('.flash-alert')).toBeNull(); + done(); + }); + }); + + it('does not pass the error further and flashes an alert if error is not 404', done => { + mock.onGet(/(.*)/).replyOnce(418); + + getBranchData(...callParams) + .then(done.fail) + .catch(e => { + expect(dispatch.mock.calls).toHaveLength(0); + expect(e.response).toBeUndefined(); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + done(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/extend_spec.js b/spec/frontend/ide/stores/extend_spec.js new file mode 100644 index 00000000000..b0f1063153e --- /dev/null +++ b/spec/frontend/ide/stores/extend_spec.js @@ -0,0 +1,74 @@ +import extendStore from '~/ide/stores/extend'; +import terminalPlugin from '~/ide/stores/plugins/terminal'; +import terminalSyncPlugin from '~/ide/stores/plugins/terminal_sync'; + +jest.mock('~/ide/stores/plugins/terminal', () => jest.fn()); +jest.mock('~/ide/stores/plugins/terminal_sync', () => jest.fn()); + +describe('ide/stores/extend', () => { + let prevGon; + let store; + let el; + + beforeEach(() => { + prevGon = global.gon; + store = {}; + el = {}; + + [terminalPlugin, terminalSyncPlugin].forEach(x => { + const plugin = jest.fn(); + + x.mockImplementation(() => plugin); + }); + }); + + afterEach(() => { + global.gon = prevGon; + terminalPlugin.mockClear(); + terminalSyncPlugin.mockClear(); + }); + + const withGonFeatures = features => { + global.gon = { ...global.gon, features }; + }; + + describe('terminalPlugin', () => { + beforeEach(() => { + extendStore(store, el); + }); + + it('is created', () => { + expect(terminalPlugin).toHaveBeenCalledWith(el); + }); + + it('is called with store', () => { + expect(terminalPlugin()).toHaveBeenCalledWith(store); + }); + }); + + describe('terminalSyncPlugin', () => { + describe('when buildServiceProxy feature is enabled', () => { + beforeEach(() => { + withGonFeatures({ buildServiceProxy: true }); + + extendStore(store, el); + }); + + it('is created', () => { + expect(terminalSyncPlugin).toHaveBeenCalledWith(el); + }); + + it('is called with store', () => { + expect(terminalSyncPlugin()).toHaveBeenCalledWith(store); + }); + }); + + describe('when buildServiceProxy feature is disabled', () => { + it('is not created', () => { + extendStore(store, el); + + expect(terminalSyncPlugin).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js index 408ea2b2939..dcf05329ce0 100644 --- a/spec/frontend/ide/stores/getters_spec.js +++ b/spec/frontend/ide/stores/getters_spec.js @@ -417,4 +417,69 @@ describe('IDE store getters', () => { expect(localStore.getters[getterName]).toBe(val); }); }); + + describe('entryExists', () => { + beforeEach(() => { + localState.entries = { + foo: file('foo', 'foo', 'tree'), + 'foo/bar.png': file(), + }; + }); + + it.each` + path | deleted | value + ${'foo/bar.png'} | ${false} | ${true} + ${'foo/bar.png'} | ${true} | ${false} + ${'foo'} | ${false} | ${true} + `( + 'returns $value for an existing entry path: $path (deleted: $deleted)', + ({ path, deleted, value }) => { + localState.entries[path].deleted = deleted; + + expect(localStore.getters.entryExists(path)).toBe(value); + }, + ); + + it('returns false for a non existing entry path', () => { + expect(localStore.getters.entryExists('bar.baz')).toBe(false); + }); + }); + + describe('getAvailableFileName', () => { + it.each` + path | newPath + ${'foo'} | ${'foo_1'} + ${'foo__93.png'} | ${'foo__94.png'} + ${'foo/bar.png'} | ${'foo/bar_1.png'} + ${'foo/bar--34.png'} | ${'foo/bar--35.png'} + ${'foo/bar 2.png'} | ${'foo/bar 3.png'} + ${'foo/bar-621.png'} | ${'foo/bar-622.png'} + ${'jquery.min.js'} | ${'jquery_1.min.js'} + ${'my_spec_22.js.snap'} | ${'my_spec_23.js.snap'} + ${'subtitles5.mp4.srt'} | ${'subtitles_6.mp4.srt'} + ${'sample_file.mp3'} | ${'sample_file_1.mp3'} + ${'Screenshot 2020-05-26 at 10.53.08 PM.png'} | ${'Screenshot 2020-05-26 at 11.53.08 PM.png'} + `('suffixes the path with a number if the path already exists', ({ path, newPath }) => { + localState.entries[path] = file(); + + expect(localStore.getters.getAvailableFileName(path)).toBe(newPath); + }); + + it('loops through all incremented entries and keeps trying until a file path that does not exist is found', () => { + localState.entries = { + 'bar/baz_1.png': file(), + 'bar/baz_2.png': file(), + 'bar/baz_3.png': file(), + 'bar/baz_4.png': file(), + 'bar/baz_5.png': file(), + 'bar/baz_72.png': file(), + }; + + expect(localStore.getters.getAvailableFileName('bar/baz_1.png')).toBe('bar/baz_6.png'); + }); + + it('returns the entry path as is if the path does not exist', () => { + expect(localStore.getters.getAvailableFileName('foo-bar1.jpg')).toBe('foo-bar1.jpg'); + }); + }); }); diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js new file mode 100644 index 00000000000..a14879112fd --- /dev/null +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -0,0 +1,598 @@ +import { file } from 'jest/ide/helpers'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { createStore } from '~/ide/stores'; +import service from '~/ide/services'; +import { createRouter } from '~/ide/ide_router'; +import eventHub from '~/ide/eventhub'; +import consts from '~/ide/stores/modules/commit/constants'; +import * as mutationTypes from '~/ide/stores/modules/commit/mutation_types'; +import * as actions from '~/ide/stores/modules/commit/actions'; +import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants'; +import testAction from '../../../../helpers/vuex_action_helper'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), +})); + +const TEST_COMMIT_SHA = '123456789'; + +describe('IDE commit module actions', () => { + let mock; + let store; + let router; + + beforeEach(() => { + store = createStore(); + router = createRouter(store); + gon.api_version = 'v1'; + mock = new MockAdapter(axios); + jest.spyOn(router, 'push').mockImplementation(); + + mock.onGet('/api/v1/projects/abcproject/repository/branches/master').reply(200); + }); + + afterEach(() => { + delete gon.api_version; + mock.restore(); + }); + + describe('updateCommitMessage', () => { + it('updates store with new commit message', done => { + store + .dispatch('commit/updateCommitMessage', 'testing') + .then(() => { + expect(store.state.commit.commitMessage).toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardDraft', () => { + it('resets commit message to blank', done => { + store.state.commit.commitMessage = 'testing'; + + store + .dispatch('commit/discardDraft') + .then(() => { + expect(store.state.commit.commitMessage).not.toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCommitAction', () => { + it('updates store with new commit action', done => { + store + .dispatch('commit/updateCommitAction', '1') + .then(() => { + expect(store.state.commit.commitAction).toBe('1'); + }) + .then(done) + .catch(done.fail); + }); + + it('sets shouldCreateMR to true if "Create new MR" option is visible', done => { + Object.assign(store.state, { + shouldHideNewMrOption: false, + }); + + testAction( + actions.updateCommitAction, + {}, + store.state, + [ + { + type: mutationTypes.UPDATE_COMMIT_ACTION, + payload: { commitAction: expect.anything() }, + }, + { type: mutationTypes.TOGGLE_SHOULD_CREATE_MR, payload: true }, + ], + [], + done, + ); + }); + + it('sets shouldCreateMR to false if "Create new MR" option is hidden', done => { + Object.assign(store.state, { + shouldHideNewMrOption: true, + }); + + testAction( + actions.updateCommitAction, + {}, + store.state, + [ + { + type: mutationTypes.UPDATE_COMMIT_ACTION, + payload: { commitAction: expect.anything() }, + }, + { type: mutationTypes.TOGGLE_SHOULD_CREATE_MR, payload: false }, + ], + [], + done, + ); + }); + }); + + describe('updateBranchName', () => { + it('updates store with new branch name', done => { + store + .dispatch('commit/updateBranchName', 'branch-name') + .then(() => { + expect(store.state.commit.newBranchName).toBe('branch-name'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setLastCommitMessage', () => { + beforeEach(() => { + Object.assign(store.state, { + currentProjectId: 'abcproject', + projects: { + abcproject: { + web_url: 'http://testing', + }, + }, + }); + }); + + it('updates commit message with short_id', done => { + store + .dispatch('commit/setLastCommitMessage', { short_id: '123' }) + .then(() => { + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('updates commit message with stats', done => { + store + .dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateFilesAfterCommit', () => { + const data = { + id: '123', + message: 'testing commit message', + committed_date: '123', + committer_name: 'root', + }; + const branch = 'master'; + let f; + + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + + f = file('changedFile'); + Object.assign(f, { + active: true, + changed: true, + content: 'file content', + }); + + Object.assign(store.state, { + currentProjectId: 'abcproject', + currentBranchId: 'master', + projects: { + abcproject: { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + commit: { + short_id: TEST_COMMIT_SHA, + }, + }, + }, + }, + }, + stagedFiles: [ + f, + { + ...file('changedFile2'), + changed: true, + }, + ], + }); + + store.state.openFiles = store.state.stagedFiles; + store.state.stagedFiles.forEach(stagedFile => { + store.state.entries[stagedFile.path] = stagedFile; + }); + }); + + it('updates stores working reference', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.projects.abcproject.branches.master.workingReference).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('resets all files changed status', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + store.state.openFiles.forEach(entry => { + expect(entry.changed).toBeFalsy(); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('sets files commit data', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.lastCommitSha).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('updates raw content for changed file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.raw).toBe(f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('emits changed event for file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, { + content: f.content, + changed: false, + }); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + + const f = { + ...file('changed'), + type: 'blob', + active: true, + lastCommitSha: TEST_COMMIT_SHA, + content: '\n', + raw: '\n', + }; + + Object.assign(store.state, { + stagedFiles: [f], + changedFiles: [f], + openFiles: [f], + currentProjectId: 'abcproject', + currentBranchId: 'master', + projects: { + abcproject: { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + commit: { + id: TEST_COMMIT_SHA, + }, + }, + }, + userPermissions: { + [PERMISSION_CREATE_MR]: true, + }, + }, + }, + }); + + store.state.commit.commitAction = '2'; + store.state.commit.commitMessage = 'testing 123'; + + store.state.openFiles.forEach(localF => { + store.state.entries[localF.path] = localF; + }); + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + const COMMIT_RESPONSE = { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + parent_ids: '321', + stats: { + additions: '1', + deletions: '2', + }, + }; + + beforeEach(() => { + jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); + }); + + it('calls service', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: expect.anything(), + commit_message: 'testing 123', + actions: [ + { + action: commitActionTypes.update, + file_path: expect.anything(), + content: '\n', + encoding: expect.anything(), + last_commit_id: undefined, + previous_path: undefined, + }, + ], + start_sha: TEST_COMMIT_SHA, + }); + + done(); + }) + .catch(done.fail); + }); + + it('sends lastCommit ID when not creating new branch', done => { + store.state.commit.commitAction = '1'; + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: expect.anything(), + commit_message: 'testing 123', + actions: [ + { + action: commitActionTypes.update, + file_path: expect.anything(), + content: '\n', + encoding: expect.anything(), + last_commit_id: TEST_COMMIT_SHA, + previous_path: undefined, + }, + ], + start_sha: undefined, + }); + + done(); + }) + .catch(done.fail); + }); + + it('sets last Commit Msg', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds commit data to files', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe( + COMMIT_RESPONSE.id, + ); + + done(); + }) + .catch(done.fail); + }); + + it('resets stores commit actions', done => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.commit.commitAction).not.toBe(consts.COMMIT_TO_NEW_BRANCH); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all staged files', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.stagedFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + describe('merge request', () => { + it('redirects to new merge request page', done => { + jest.spyOn(eventHub, '$on').mockImplementation(); + + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + store.state.commit.shouldCreateMR = true; + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(visitUrl).toHaveBeenCalledWith( + `webUrl/-/merge_requests/new?merge_request[source_branch]=${ + store.getters['commit/placeholderBranchName'] + }&merge_request[target_branch]=master&nav_source=webide`, + ); + + done(); + }) + .catch(done.fail); + }); + + it('does not redirect to new merge request page when shouldCreateMR is not checked', done => { + jest.spyOn(eventHub, '$on').mockImplementation(); + + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + store.state.commit.shouldCreateMR = false; + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(visitUrl).not.toHaveBeenCalled(); + done(); + }) + .catch(done.fail); + }); + + it('resets changed files before redirecting', () => { + jest.spyOn(eventHub, '$on').mockImplementation(); + + store.state.commit.commitAction = '3'; + + return store.dispatch('commit/commitChanges').then(() => { + expect(store.state.stagedFiles.length).toBe(0); + }); + }); + }); + }); + + describe('failed', () => { + beforeEach(() => { + jest.spyOn(service, 'commit').mockResolvedValue({ + data: { + message: 'failed message', + }, + }); + }); + + it('shows failed message', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe('failed message'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('first commit of a branch', () => { + const COMMIT_RESPONSE = { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + parent_ids: [], + stats: { + additions: '1', + deletions: '2', + }, + }; + + it('commits TOGGLE_EMPTY_STATE mutation on empty repo', done => { + jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); + jest.spyOn(store, 'commit'); + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.commit.mock.calls).toEqual( + expect.arrayContaining([ + ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)], + ]), + ); + done(); + }) + .catch(done.fail); + }); + + it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', done => { + COMMIT_RESPONSE.parent_ids.push('1234'); + jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE }); + jest.spyOn(store, 'commit'); + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.commit.mock.calls).not.toEqual( + expect.arrayContaining([ + ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)], + ]), + ); + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('toggleShouldCreateMR', () => { + it('commits both toggle and interacting with MR checkbox actions', done => { + testAction( + actions.toggleShouldCreateMR, + {}, + store.state, + [{ type: mutationTypes.TOGGLE_SHOULD_CREATE_MR }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/pane/getters_spec.js b/spec/frontend/ide/stores/modules/pane/getters_spec.js index 8a213323de0..a321571f058 100644 --- a/spec/frontend/ide/stores/modules/pane/getters_spec.js +++ b/spec/frontend/ide/stores/modules/pane/getters_spec.js @@ -7,20 +7,6 @@ describe('IDE pane module getters', () => { [TEST_VIEW]: true, }; - describe('isActiveView', () => { - it('returns true if given view matches currentView', () => { - const result = getters.isActiveView({ currentView: 'A' })('A'); - - expect(result).toBe(true); - }); - - it('returns false if given view does not match currentView', () => { - const result = getters.isActiveView({ currentView: 'A' })('B'); - - expect(result).toBe(false); - }); - }); - describe('isAliveView', () => { it('returns true if given view is in keepAliveViews', () => { const result = getters.isAliveView({ keepAliveViews: TEST_KEEP_ALIVE_VIEWS }, {})(TEST_VIEW); @@ -29,25 +15,25 @@ describe('IDE pane module getters', () => { }); it('returns true if given view is active view and open', () => { - const result = getters.isAliveView( - { ...state(), isOpen: true }, - { isActiveView: () => true }, - )(TEST_VIEW); + const result = getters.isAliveView({ ...state(), isOpen: true, currentView: TEST_VIEW })( + TEST_VIEW, + ); expect(result).toBe(true); }); it('returns false if given view is active view and closed', () => { - const result = getters.isAliveView(state(), { isActiveView: () => true })(TEST_VIEW); + const result = getters.isAliveView({ ...state(), currentView: TEST_VIEW })(TEST_VIEW); expect(result).toBe(false); }); it('returns false if given view is not activeView', () => { - const result = getters.isAliveView( - { ...state(), isOpen: true }, - { isActiveView: () => false }, - )(TEST_VIEW); + const result = getters.isAliveView({ + ...state(), + isOpen: true, + currentView: `${TEST_VIEW}_other`, + })(TEST_VIEW); expect(result).toBe(false); }); diff --git a/spec/frontend/ide/stores/modules/router/actions_spec.js b/spec/frontend/ide/stores/modules/router/actions_spec.js new file mode 100644 index 00000000000..4795eae2b79 --- /dev/null +++ b/spec/frontend/ide/stores/modules/router/actions_spec.js @@ -0,0 +1,19 @@ +import * as actions from '~/ide/stores/modules/router/actions'; +import * as types from '~/ide/stores/modules/router/mutation_types'; +import testAction from 'helpers/vuex_action_helper'; + +const TEST_PATH = 'test/path/abc'; + +describe('ide/stores/modules/router/actions', () => { + describe('push', () => { + it('commits mutation', () => { + return testAction( + actions.push, + TEST_PATH, + {}, + [{ type: types.PUSH, payload: TEST_PATH }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/router/mutations_spec.js b/spec/frontend/ide/stores/modules/router/mutations_spec.js new file mode 100644 index 00000000000..a4a83c9344d --- /dev/null +++ b/spec/frontend/ide/stores/modules/router/mutations_spec.js @@ -0,0 +1,23 @@ +import mutations from '~/ide/stores/modules/router/mutations'; +import * as types from '~/ide/stores/modules/router/mutation_types'; +import createState from '~/ide/stores/modules/router/state'; + +const TEST_PATH = 'test/path/abc'; + +describe('ide/stores/modules/router/mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.PUSH, () => { + it('updates state', () => { + expect(state.fullPath).toBe(''); + + mutations[types.PUSH](state, TEST_PATH); + + expect(state.fullPath).toBe(TEST_PATH); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js new file mode 100644 index 00000000000..242b1579be7 --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js @@ -0,0 +1,289 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import { + CHECK_CONFIG, + CHECK_RUNNERS, + RETRY_RUNNERS_INTERVAL, +} from '~/ide/stores/modules/terminal/constants'; +import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; +import * as messages from '~/ide/stores/modules/terminal/messages'; +import * as actions from '~/ide/stores/modules/terminal/actions/checks'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; + +const TEST_PROJECT_PATH = 'lorem/root'; +const TEST_BRANCH_ID = 'master'; +const TEST_YAML_HELP_PATH = `${TEST_HOST}/test/yaml/help`; +const TEST_RUNNERS_HELP_PATH = `${TEST_HOST}/test/runners/help`; + +describe('IDE store terminal check actions', () => { + let mock; + let state; + let rootState; + let rootGetters; + + beforeEach(() => { + mock = new MockAdapter(axios); + state = { + paths: { + webTerminalConfigHelpPath: TEST_YAML_HELP_PATH, + webTerminalRunnersHelpPath: TEST_RUNNERS_HELP_PATH, + }, + checks: { + config: { isLoading: true }, + }, + }; + rootState = { + currentBranchId: TEST_BRANCH_ID, + }; + rootGetters = { + currentProject: { + id: 7, + path_with_namespace: TEST_PROJECT_PATH, + }, + }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('requestConfigCheck', () => { + it('handles request loading', () => { + return testAction( + actions.requestConfigCheck, + null, + {}, + [{ type: mutationTypes.REQUEST_CHECK, payload: CHECK_CONFIG }], + [], + ); + }); + }); + + describe('receiveConfigCheckSuccess', () => { + it('handles successful response', () => { + return testAction( + actions.receiveConfigCheckSuccess, + null, + {}, + [ + { type: mutationTypes.SET_VISIBLE, payload: true }, + { type: mutationTypes.RECEIVE_CHECK_SUCCESS, payload: CHECK_CONFIG }, + ], + [], + ); + }); + }); + + describe('receiveConfigCheckError', () => { + it('handles error response', () => { + const status = httpStatus.UNPROCESSABLE_ENTITY; + const payload = { response: { status } }; + + return testAction( + actions.receiveConfigCheckError, + payload, + state, + [ + { + type: mutationTypes.SET_VISIBLE, + payload: true, + }, + { + type: mutationTypes.RECEIVE_CHECK_ERROR, + payload: { + type: CHECK_CONFIG, + message: messages.configCheckError(status, TEST_YAML_HELP_PATH), + }, + }, + ], + [], + ); + }); + + [httpStatus.FORBIDDEN, httpStatus.NOT_FOUND].forEach(status => { + it(`hides tab, when status is ${status}`, () => { + const payload = { response: { status } }; + + return testAction( + actions.receiveConfigCheckError, + payload, + state, + [ + { + type: mutationTypes.SET_VISIBLE, + payload: false, + }, + expect.objectContaining({ type: mutationTypes.RECEIVE_CHECK_ERROR }), + ], + [], + ); + }); + }); + }); + + describe('fetchConfigCheck', () => { + it('dispatches request and receive', () => { + mock.onPost(/.*\/ide_terminals\/check_config/).reply(200, {}); + + return testAction( + actions.fetchConfigCheck, + null, + { + ...rootGetters, + ...rootState, + }, + [], + [{ type: 'requestConfigCheck' }, { type: 'receiveConfigCheckSuccess' }], + ); + }); + + it('when error, dispatches request and receive', () => { + mock.onPost(/.*\/ide_terminals\/check_config/).reply(400, {}); + + return testAction( + actions.fetchConfigCheck, + null, + { + ...rootGetters, + ...rootState, + }, + [], + [ + { type: 'requestConfigCheck' }, + { type: 'receiveConfigCheckError', payload: expect.any(Error) }, + ], + ); + }); + }); + + describe('requestRunnersCheck', () => { + it('handles request loading', () => { + return testAction( + actions.requestRunnersCheck, + null, + {}, + [{ type: mutationTypes.REQUEST_CHECK, payload: CHECK_RUNNERS }], + [], + ); + }); + }); + + describe('receiveRunnersCheckSuccess', () => { + it('handles successful response, with data', () => { + const payload = [{}]; + + return testAction( + actions.receiveRunnersCheckSuccess, + payload, + state, + [{ type: mutationTypes.RECEIVE_CHECK_SUCCESS, payload: CHECK_RUNNERS }], + [], + ); + }); + + it('handles successful response, with empty data', () => { + const commitPayload = { + type: CHECK_RUNNERS, + message: messages.runnersCheckEmpty(TEST_RUNNERS_HELP_PATH), + }; + + return testAction( + actions.receiveRunnersCheckSuccess, + [], + state, + [{ type: mutationTypes.RECEIVE_CHECK_ERROR, payload: commitPayload }], + [{ type: 'retryRunnersCheck' }], + ); + }); + }); + + describe('receiveRunnersCheckError', () => { + it('dispatches handle with message', () => { + const commitPayload = { + type: CHECK_RUNNERS, + message: messages.UNEXPECTED_ERROR_RUNNERS, + }; + + return testAction( + actions.receiveRunnersCheckError, + null, + {}, + [{ type: mutationTypes.RECEIVE_CHECK_ERROR, payload: commitPayload }], + [], + ); + }); + }); + + describe('retryRunnersCheck', () => { + it('dispatches fetch again after timeout', () => { + const dispatch = jest.fn().mockName('dispatch'); + + actions.retryRunnersCheck({ dispatch, state }); + + expect(dispatch).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(RETRY_RUNNERS_INTERVAL + 1); + + expect(dispatch).toHaveBeenCalledWith('fetchRunnersCheck', { background: true }); + }); + + it('does not dispatch fetch if config check is error', () => { + const dispatch = jest.fn().mockName('dispatch'); + state.checks.config = { + isLoading: false, + isValid: false, + }; + + actions.retryRunnersCheck({ dispatch, state }); + + expect(dispatch).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(RETRY_RUNNERS_INTERVAL + 1); + + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('fetchRunnersCheck', () => { + it('dispatches request and receive', () => { + mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(200, []); + + return testAction( + actions.fetchRunnersCheck, + {}, + rootGetters, + [], + [{ type: 'requestRunnersCheck' }, { type: 'receiveRunnersCheckSuccess', payload: [] }], + ); + }); + + it('does not dispatch request when background is true', () => { + mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(200, []); + + return testAction( + actions.fetchRunnersCheck, + { background: true }, + rootGetters, + [], + [{ type: 'receiveRunnersCheckSuccess', payload: [] }], + ); + }); + + it('dispatches request and receive, when error', () => { + mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(500, []); + + return testAction( + actions.fetchRunnersCheck, + {}, + rootGetters, + [], + [ + { type: 'requestRunnersCheck' }, + { type: 'receiveRunnersCheckError', payload: expect.any(Error) }, + ], + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js new file mode 100644 index 00000000000..4bc937b4784 --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js @@ -0,0 +1,300 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { STARTING, PENDING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants'; +import * as messages from '~/ide/stores/modules/terminal/messages'; +import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; +import * as actions from '~/ide/stores/modules/terminal/actions/session_controls'; +import httpStatus from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); + +const TEST_PROJECT_PATH = 'lorem/root'; +const TEST_BRANCH_ID = 'master'; +const TEST_SESSION = { + id: 7, + status: PENDING, + show_path: 'path/show', + cancel_path: 'path/cancel', + retry_path: 'path/retry', + terminal_path: 'path/terminal', + proxy_websocket_path: 'path/proxy', + services: ['test-service'], +}; + +describe('IDE store terminal session controls actions', () => { + let mock; + let dispatch; + let rootState; + let rootGetters; + + beforeEach(() => { + mock = new MockAdapter(axios); + dispatch = jest.fn().mockName('dispatch'); + rootState = { + currentBranchId: TEST_BRANCH_ID, + }; + rootGetters = { + currentProject: { + id: 7, + path_with_namespace: TEST_PROJECT_PATH, + }, + }; + }); + + afterEach(() => { + mock.restore(); + }); + + describe('requestStartSession', () => { + it('sets session status', () => { + return testAction( + actions.requestStartSession, + null, + {}, + [{ type: mutationTypes.SET_SESSION_STATUS, payload: STARTING }], + [], + ); + }); + }); + + describe('receiveStartSessionSuccess', () => { + it('sets session and starts polling status', () => { + return testAction( + actions.receiveStartSessionSuccess, + TEST_SESSION, + {}, + [ + { + type: mutationTypes.SET_SESSION, + payload: { + id: TEST_SESSION.id, + status: TEST_SESSION.status, + showPath: TEST_SESSION.show_path, + cancelPath: TEST_SESSION.cancel_path, + retryPath: TEST_SESSION.retry_path, + terminalPath: TEST_SESSION.terminal_path, + proxyWebsocketPath: TEST_SESSION.proxy_websocket_path, + services: TEST_SESSION.services, + }, + }, + ], + [{ type: 'pollSessionStatus' }], + ); + }); + }); + + describe('receiveStartSessionError', () => { + it('flashes message', () => { + actions.receiveStartSessionError({ dispatch }); + + expect(createFlash).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STARTING); + }); + + it('sets session status', () => { + return testAction(actions.receiveStartSessionError, null, {}, [], [{ type: 'killSession' }]); + }); + }); + + describe('startSession', () => { + it('does nothing if session is already starting', () => { + const state = { + session: { status: STARTING }, + }; + + actions.startSession({ state, dispatch }); + + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches request and receive on success', () => { + mock.onPost(/.*\/ide_terminals/).reply(200, TEST_SESSION); + + return testAction( + actions.startSession, + null, + { ...rootGetters, ...rootState }, + [], + [ + { type: 'requestStartSession' }, + { type: 'receiveStartSessionSuccess', payload: TEST_SESSION }, + ], + ); + }); + + it('dispatches request and receive on error', () => { + mock.onPost(/.*\/ide_terminals/).reply(400); + + return testAction( + actions.startSession, + null, + { ...rootGetters, ...rootState }, + [], + [ + { type: 'requestStartSession' }, + { type: 'receiveStartSessionError', payload: expect.any(Error) }, + ], + ); + }); + }); + + describe('requestStopSession', () => { + it('sets session status', () => { + return testAction( + actions.requestStopSession, + null, + {}, + [{ type: mutationTypes.SET_SESSION_STATUS, payload: STOPPING }], + [], + ); + }); + }); + + describe('receiveStopSessionSuccess', () => { + it('kills the session', () => { + return testAction(actions.receiveStopSessionSuccess, null, {}, [], [{ type: 'killSession' }]); + }); + }); + + describe('receiveStopSessionError', () => { + it('flashes message', () => { + actions.receiveStopSessionError({ dispatch }); + + expect(createFlash).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STOPPING); + }); + + it('kills the session', () => { + return testAction(actions.receiveStopSessionError, null, {}, [], [{ type: 'killSession' }]); + }); + }); + + describe('stopSession', () => { + it('dispatches request and receive on success', () => { + mock.onPost(TEST_SESSION.cancel_path).reply(200, {}); + + const state = { + session: { cancelPath: TEST_SESSION.cancel_path }, + }; + + return testAction( + actions.stopSession, + null, + state, + [], + [{ type: 'requestStopSession' }, { type: 'receiveStopSessionSuccess' }], + ); + }); + + it('dispatches request and receive on error', () => { + mock.onPost(TEST_SESSION.cancel_path).reply(400); + + const state = { + session: { cancelPath: TEST_SESSION.cancel_path }, + }; + + return testAction( + actions.stopSession, + null, + state, + [], + [ + { type: 'requestStopSession' }, + { type: 'receiveStopSessionError', payload: expect.any(Error) }, + ], + ); + }); + }); + + describe('killSession', () => { + it('stops polling and sets status', () => { + return testAction( + actions.killSession, + null, + {}, + [{ type: mutationTypes.SET_SESSION_STATUS, payload: STOPPED }], + [{ type: 'stopPollingSessionStatus' }], + ); + }); + }); + + describe('restartSession', () => { + let state; + + beforeEach(() => { + state = { + session: { status: STOPPED, retryPath: 'test/retry' }, + }; + }); + + it('does nothing if current not stopped', () => { + state.session.status = STOPPING; + + actions.restartSession({ state, dispatch, rootState }); + + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches startSession if retryPath is empty', () => { + state.session.retryPath = ''; + + return testAction( + actions.restartSession, + null, + { ...state, ...rootState }, + [], + [{ type: 'startSession' }], + ); + }); + + it('dispatches request and receive on success', () => { + mock + .onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' }) + .reply(200, TEST_SESSION); + + return testAction( + actions.restartSession, + null, + { ...state, ...rootState }, + [], + [ + { type: 'requestStartSession' }, + { type: 'receiveStartSessionSuccess', payload: TEST_SESSION }, + ], + ); + }); + + it('dispatches request and receive on error', () => { + mock + .onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' }) + .reply(400); + + return testAction( + actions.restartSession, + null, + { ...state, ...rootState }, + [], + [ + { type: 'requestStartSession' }, + { type: 'receiveStartSessionError', payload: expect.any(Error) }, + ], + ); + }); + + [httpStatus.NOT_FOUND, httpStatus.UNPROCESSABLE_ENTITY].forEach(status => { + it(`dispatches request and startSession on ${status}`, () => { + mock + .onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' }) + .reply(status); + + return testAction( + actions.restartSession, + null, + { ...state, ...rootState }, + [], + [{ type: 'requestStartSession' }, { type: 'startSession' }], + ); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js new file mode 100644 index 00000000000..7909f828124 --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js @@ -0,0 +1,169 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { PENDING, RUNNING, STOPPING, STOPPED } from '~/ide/stores/modules/terminal/constants'; +import * as messages from '~/ide/stores/modules/terminal/messages'; +import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; +import * as actions from '~/ide/stores/modules/terminal/actions/session_status'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); + +const TEST_SESSION = { + id: 7, + status: PENDING, + show_path: 'path/show', + cancel_path: 'path/cancel', + retry_path: 'path/retry', + terminal_path: 'path/terminal', +}; + +describe('IDE store terminal session controls actions', () => { + let mock; + let dispatch; + let commit; + + beforeEach(() => { + mock = new MockAdapter(axios); + dispatch = jest.fn().mockName('dispatch'); + commit = jest.fn().mockName('commit'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('pollSessionStatus', () => { + it('starts interval to poll status', () => { + return testAction( + actions.pollSessionStatus, + null, + {}, + [{ type: mutationTypes.SET_SESSION_STATUS_INTERVAL, payload: expect.any(Number) }], + [{ type: 'stopPollingSessionStatus' }, { type: 'fetchSessionStatus' }], + ); + }); + + it('on interval, stops polling if no session', () => { + const state = { + session: null, + }; + + actions.pollSessionStatus({ state, dispatch, commit }); + dispatch.mockClear(); + + jest.advanceTimersByTime(5001); + + expect(dispatch).toHaveBeenCalledWith('stopPollingSessionStatus'); + }); + + it('on interval, fetches status', () => { + const state = { + session: TEST_SESSION, + }; + + actions.pollSessionStatus({ state, dispatch, commit }); + dispatch.mockClear(); + + jest.advanceTimersByTime(5001); + + expect(dispatch).toHaveBeenCalledWith('fetchSessionStatus'); + }); + }); + + describe('stopPollingSessionStatus', () => { + it('does nothing if sessionStatusInterval is empty', () => { + return testAction(actions.stopPollingSessionStatus, null, {}, [], []); + }); + + it('clears interval', () => { + return testAction( + actions.stopPollingSessionStatus, + null, + { sessionStatusInterval: 7 }, + [{ type: mutationTypes.SET_SESSION_STATUS_INTERVAL, payload: 0 }], + [], + ); + }); + }); + + describe('receiveSessionStatusSuccess', () => { + it('sets session status', () => { + return testAction( + actions.receiveSessionStatusSuccess, + { status: RUNNING }, + {}, + [{ type: mutationTypes.SET_SESSION_STATUS, payload: RUNNING }], + [], + ); + }); + + [STOPPING, STOPPED, 'unexpected'].forEach(status => { + it(`kills session if status is ${status}`, () => { + return testAction( + actions.receiveSessionStatusSuccess, + { status }, + {}, + [{ type: mutationTypes.SET_SESSION_STATUS, payload: status }], + [{ type: 'killSession' }], + ); + }); + }); + }); + + describe('receiveSessionStatusError', () => { + it('flashes message', () => { + actions.receiveSessionStatusError({ dispatch }); + + expect(createFlash).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STATUS); + }); + + it('kills the session', () => { + return testAction(actions.receiveSessionStatusError, null, {}, [], [{ type: 'killSession' }]); + }); + }); + + describe('fetchSessionStatus', () => { + let state; + + beforeEach(() => { + state = { + session: { + showPath: TEST_SESSION.show_path, + }, + }; + }); + + it('does nothing if session is falsey', () => { + state.session = null; + + actions.fetchSessionStatus({ dispatch, state }); + + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches success on success', () => { + mock.onGet(state.session.showPath).reply(200, TEST_SESSION); + + return testAction( + actions.fetchSessionStatus, + null, + state, + [], + [{ type: 'receiveSessionStatusSuccess', payload: TEST_SESSION }], + ); + }); + + it('dispatches error on error', () => { + mock.onGet(state.session.showPath).reply(400); + + return testAction( + actions.fetchSessionStatus, + null, + state, + [], + [{ type: 'receiveSessionStatusError', payload: expect.any(Error) }], + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/setup_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/setup_spec.js new file mode 100644 index 00000000000..8bf3b58228e --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/actions/setup_spec.js @@ -0,0 +1,40 @@ +import testAction from 'helpers/vuex_action_helper'; +import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; +import * as actions from '~/ide/stores/modules/terminal/actions/setup'; + +describe('IDE store terminal setup actions', () => { + describe('init', () => { + it('dispatches checks', () => { + return testAction( + actions.init, + null, + {}, + [], + [{ type: 'fetchConfigCheck' }, { type: 'fetchRunnersCheck' }], + ); + }); + }); + + describe('hideSplash', () => { + it('commits HIDE_SPLASH', () => { + return testAction(actions.hideSplash, null, {}, [{ type: mutationTypes.HIDE_SPLASH }], []); + }); + }); + + describe('setPaths', () => { + it('commits SET_PATHS', () => { + const paths = { + foo: 'bar', + lorem: 'ipsum', + }; + + return testAction( + actions.setPaths, + paths, + {}, + [{ type: mutationTypes.SET_PATHS, payload: paths }], + [], + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/getters_spec.js b/spec/frontend/ide/stores/modules/terminal/getters_spec.js new file mode 100644 index 00000000000..b5d6a4bc746 --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/getters_spec.js @@ -0,0 +1,50 @@ +import { CHECK_CONFIG, CHECK_RUNNERS } from '~/ide/stores/modules/terminal/constants'; +import * as getters from '~/ide/stores/modules/terminal/getters'; + +describe('IDE store terminal getters', () => { + describe('allCheck', () => { + it('is loading if one check is loading', () => { + const checks = { + [CHECK_CONFIG]: { isLoading: false, isValid: true }, + [CHECK_RUNNERS]: { isLoading: true }, + }; + + const result = getters.allCheck({ checks }); + + expect(result).toEqual({ + isLoading: true, + }); + }); + + it('is invalid if one check is invalid', () => { + const message = 'lorem ipsum'; + const checks = { + [CHECK_CONFIG]: { isLoading: false, isValid: false, message }, + [CHECK_RUNNERS]: { isLoading: false, isValid: true }, + }; + + const result = getters.allCheck({ checks }); + + expect(result).toEqual({ + isLoading: false, + isValid: false, + message, + }); + }); + + it('is valid if all checks are valid', () => { + const checks = { + [CHECK_CONFIG]: { isLoading: false, isValid: true }, + [CHECK_RUNNERS]: { isLoading: false, isValid: true }, + }; + + const result = getters.allCheck({ checks }); + + expect(result).toEqual({ + isLoading: false, + isValid: true, + message: '', + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/messages_spec.js b/spec/frontend/ide/stores/modules/terminal/messages_spec.js new file mode 100644 index 00000000000..966158999da --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/messages_spec.js @@ -0,0 +1,38 @@ +import { escape } from 'lodash'; +import { TEST_HOST } from 'spec/test_constants'; +import * as messages from '~/ide/stores/modules/terminal/messages'; +import { sprintf } from '~/locale'; +import httpStatus from '~/lib/utils/http_status'; + +const TEST_HELP_URL = `${TEST_HOST}/help`; + +describe('IDE store terminal messages', () => { + describe('configCheckError', () => { + it('returns job error, with status UNPROCESSABLE_ENTITY', () => { + const result = messages.configCheckError(httpStatus.UNPROCESSABLE_ENTITY, TEST_HELP_URL); + + expect(result).toBe( + sprintf( + messages.ERROR_CONFIG, + { + helpStart: `<a href="${escape(TEST_HELP_URL)}" target="_blank">`, + helpEnd: '</a>', + }, + false, + ), + ); + }); + + it('returns permission error, with status FORBIDDEN', () => { + const result = messages.configCheckError(httpStatus.FORBIDDEN, TEST_HELP_URL); + + expect(result).toBe(messages.ERROR_PERMISSION); + }); + + it('returns unexpected error, with unexpected status', () => { + const result = messages.configCheckError(httpStatus.NOT_FOUND, TEST_HELP_URL); + + expect(result).toBe(messages.UNEXPECTED_ERROR_CONFIG); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal/mutations_spec.js b/spec/frontend/ide/stores/modules/terminal/mutations_spec.js new file mode 100644 index 00000000000..e9933bdd7be --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal/mutations_spec.js @@ -0,0 +1,142 @@ +import { + CHECK_CONFIG, + CHECK_RUNNERS, + RUNNING, + STOPPING, +} from '~/ide/stores/modules/terminal/constants'; +import createState from '~/ide/stores/modules/terminal/state'; +import * as types from '~/ide/stores/modules/terminal/mutation_types'; +import mutations from '~/ide/stores/modules/terminal/mutations'; + +describe('IDE store terminal mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.SET_VISIBLE, () => { + it('sets isVisible', () => { + state.isVisible = false; + + mutations[types.SET_VISIBLE](state, true); + + expect(state.isVisible).toBe(true); + }); + }); + + describe(types.HIDE_SPLASH, () => { + it('sets isShowSplash', () => { + state.isShowSplash = true; + + mutations[types.HIDE_SPLASH](state); + + expect(state.isShowSplash).toBe(false); + }); + }); + + describe(types.SET_PATHS, () => { + it('sets paths', () => { + const paths = { + test: 'foo', + }; + + mutations[types.SET_PATHS](state, paths); + + expect(state.paths).toBe(paths); + }); + }); + + describe(types.REQUEST_CHECK, () => { + it('sets isLoading for check', () => { + const type = CHECK_CONFIG; + + state.checks[type] = {}; + mutations[types.REQUEST_CHECK](state, type); + + expect(state.checks[type]).toEqual({ + isLoading: true, + }); + }); + }); + + describe(types.RECEIVE_CHECK_ERROR, () => { + it('sets error for check', () => { + const type = CHECK_RUNNERS; + const message = 'lorem ipsum'; + + state.checks[type] = {}; + mutations[types.RECEIVE_CHECK_ERROR](state, { type, message }); + + expect(state.checks[type]).toEqual({ + isLoading: false, + isValid: false, + message, + }); + }); + }); + + describe(types.RECEIVE_CHECK_SUCCESS, () => { + it('sets success for check', () => { + const type = CHECK_CONFIG; + + state.checks[type] = {}; + mutations[types.RECEIVE_CHECK_SUCCESS](state, type); + + expect(state.checks[type]).toEqual({ + isLoading: false, + isValid: true, + message: null, + }); + }); + }); + + describe(types.SET_SESSION, () => { + it('sets session', () => { + const session = { + terminalPath: 'terminal/foo', + status: RUNNING, + }; + + mutations[types.SET_SESSION](state, session); + + expect(state.session).toBe(session); + }); + }); + + describe(types.SET_SESSION_STATUS, () => { + it('sets session if a session does not exists', () => { + const status = RUNNING; + + mutations[types.SET_SESSION_STATUS](state, status); + + expect(state.session).toEqual({ + status, + }); + }); + + it('sets session status', () => { + state.session = { + terminalPath: 'terminal/foo', + status: RUNNING, + }; + + mutations[types.SET_SESSION_STATUS](state, STOPPING); + + expect(state.session).toEqual({ + terminalPath: 'terminal/foo', + status: STOPPING, + }); + }); + }); + + describe(types.SET_SESSION_STATUS_INTERVAL, () => { + it('sets sessionStatusInterval', () => { + const val = 7; + + mutations[types.SET_SESSION_STATUS_INTERVAL](state, val); + + expect(state.sessionStatusInterval).toEqual(val); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js new file mode 100644 index 00000000000..ac976300ed0 --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js @@ -0,0 +1,118 @@ +import * as actions from '~/ide/stores/modules/terminal_sync/actions'; +import mirror, { canConnect, SERVICE_NAME } from '~/ide/lib/mirror'; +import * as types from '~/ide/stores/modules/terminal_sync/mutation_types'; +import testAction from 'helpers/vuex_action_helper'; + +jest.mock('~/ide/lib/mirror'); + +const TEST_SESSION = { + proxyWebsocketPath: 'test/path', + services: [SERVICE_NAME], +}; + +describe('ide/stores/modules/terminal_sync/actions', () => { + let rootState; + + beforeEach(() => { + canConnect.mockReturnValue(true); + rootState = { + changedFiles: [], + terminal: {}, + }; + }); + + describe('upload', () => { + it('uploads to mirror and sets success', done => { + mirror.upload.mockReturnValue(Promise.resolve()); + + testAction( + actions.upload, + null, + rootState, + [{ type: types.START_LOADING }, { type: types.SET_SUCCESS }], + [], + () => { + expect(mirror.upload).toHaveBeenCalledWith(rootState); + done(); + }, + ); + }); + + it('sets error when failed', done => { + const err = { message: 'it failed!' }; + mirror.upload.mockReturnValue(Promise.reject(err)); + + testAction( + actions.upload, + null, + rootState, + [{ type: types.START_LOADING }, { type: types.SET_ERROR, payload: err }], + [], + done, + ); + }); + }); + + describe('stop', () => { + it('disconnects from mirror', done => { + testAction(actions.stop, null, rootState, [{ type: types.STOP }], [], () => { + expect(mirror.disconnect).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('start', () => { + it.each` + session | canConnectMock | description + ${null} | ${true} | ${'does not exist'} + ${{}} | ${true} | ${'does not have proxyWebsocketPath'} + ${{ proxyWebsocketPath: 'test/path' }} | ${false} | ${'can not connect service'} + `('rejects if session $description', ({ session, canConnectMock }) => { + canConnect.mockReturnValue(canConnectMock); + + const result = actions.start({ rootState: { terminal: { session } } }); + + return expect(result).rejects.toBe(undefined); + }); + + describe('with terminal session in state', () => { + beforeEach(() => { + rootState = { + terminal: { session: TEST_SESSION }, + }; + }); + + it('connects to mirror and sets success', done => { + mirror.connect.mockReturnValue(Promise.resolve()); + + testAction( + actions.start, + null, + rootState, + [{ type: types.START_LOADING }, { type: types.SET_SUCCESS }], + [], + () => { + expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath); + done(); + }, + ); + }); + + it('sets error if connection fails', () => { + const commit = jest.fn(); + const err = new Error('test'); + mirror.connect.mockReturnValue(Promise.reject(err)); + + const result = actions.start({ rootState, commit }); + + return Promise.all([ + expect(result).rejects.toEqual(err), + result.catch(() => { + expect(commit).toHaveBeenCalledWith(types.SET_ERROR, err); + }), + ]); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/modules/terminal_sync/mutations_spec.js b/spec/frontend/ide/stores/modules/terminal_sync/mutations_spec.js new file mode 100644 index 00000000000..ecf35d60e96 --- /dev/null +++ b/spec/frontend/ide/stores/modules/terminal_sync/mutations_spec.js @@ -0,0 +1,89 @@ +import createState from '~/ide/stores/modules/terminal_sync/state'; +import * as types from '~/ide/stores/modules/terminal_sync/mutation_types'; +import mutations from '~/ide/stores/modules/terminal_sync/mutations'; + +const TEST_MESSAGE = 'lorem ipsum dolar sit'; + +describe('ide/stores/modules/terminal_sync/mutations', () => { + let state; + + beforeEach(() => { + state = createState(); + }); + + describe(types.START_LOADING, () => { + it('sets isLoading and resets error', () => { + Object.assign(state, { + isLoading: false, + isError: true, + }); + + mutations[types.START_LOADING](state); + + expect(state).toEqual( + expect.objectContaining({ + isLoading: true, + isError: false, + }), + ); + }); + }); + + describe(types.SET_ERROR, () => { + it('sets isLoading and error message', () => { + Object.assign(state, { + isLoading: true, + isError: false, + message: '', + }); + + mutations[types.SET_ERROR](state, { message: TEST_MESSAGE }); + + expect(state).toEqual( + expect.objectContaining({ + isLoading: false, + isError: true, + message: TEST_MESSAGE, + }), + ); + }); + }); + + describe(types.SET_SUCCESS, () => { + it('sets isLoading and resets error and is started', () => { + Object.assign(state, { + isLoading: true, + isError: true, + isStarted: false, + }); + + mutations[types.SET_SUCCESS](state); + + expect(state).toEqual( + expect.objectContaining({ + isLoading: false, + isError: false, + isStarted: true, + }), + ); + }); + }); + + describe(types.STOP, () => { + it('sets stop values', () => { + Object.assign(state, { + isLoading: true, + isStarted: true, + }); + + mutations[types.STOP](state); + + expect(state).toEqual( + expect.objectContaining({ + isLoading: false, + isStarted: false, + }), + ); + }); + }); +}); diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index 9b96b910fcb..ff904bbc9cd 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -60,22 +60,14 @@ describe('IDE store file mutations', () => { it('sets extra file data', () => { mutations.SET_FILE_DATA(localState, { data: { - blame_path: 'blame', - commits_path: 'commits', - permalink: 'permalink', raw_path: 'raw', binary: true, - render_error: 'render_error', }, file: localFile, }); - expect(localFile.blamePath).toBe('blame'); - expect(localFile.commitsPath).toBe('commits'); - expect(localFile.permalink).toBe('permalink'); expect(localFile.rawPath).toBe('raw'); expect(localFile.binary).toBeTruthy(); - expect(localFile.renderError).toBe('render_error'); expect(localFile.raw).toBeNull(); expect(localFile.baseRaw).toBeNull(); }); @@ -356,14 +348,6 @@ describe('IDE store file mutations', () => { expect(localState.changedFiles.length).toBe(1); }); - - it('bursts unused seal', () => { - expect(localState.unusedSeal).toBe(true); - - mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); - - expect(localState.unusedSeal).toBe(false); - }); }); describe('REMOVE_FILE_FROM_CHANGED', () => { @@ -374,14 +358,6 @@ describe('IDE store file mutations', () => { expect(localState.changedFiles.length).toBe(0); }); - - it('bursts unused seal', () => { - expect(localState.unusedSeal).toBe(true); - - mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); - - expect(localState.unusedSeal).toBe(false); - }); }); describe.each` @@ -533,19 +509,6 @@ describe('IDE store file mutations', () => { }, ); - describe('STAGE_CHANGE', () => { - it('bursts unused seal', () => { - expect(localState.unusedSeal).toBe(true); - - mutations.STAGE_CHANGE(localState, { - path: localFile.path, - diffInfo: localStore.getters.getDiffInfo(localFile.path), - }); - - expect(localState.unusedSeal).toBe(false); - }); - }); - describe('TOGGLE_FILE_CHANGED', () => { it('updates file changed status', () => { mutations.TOGGLE_FILE_CHANGED(localState, { diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 2eca9acb8d8..1b29648fb8b 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -120,24 +120,6 @@ describe('Multi-file store mutations', () => { expect(localState.trees['gitlab-ce/master'].tree.length).toEqual(1); expect(localState.entries.test.tempFile).toEqual(true); }); - - it('marks entry as replacing previous entry if the old one has been deleted', () => { - const tmpFile = file('test'); - localState.entries.test = { ...tmpFile, deleted: true }; - mutations.CREATE_TMP_ENTRY(localState, { - data: { - entries: { - test: { ...tmpFile, tempFile: true, changed: true }, - }, - treeList: [tmpFile], - }, - projectId: 'gitlab-ce', - branchId: 'master', - }); - - expect(localState.trees['gitlab-ce/master'].tree.length).toEqual(1); - expect(localState.entries.test.replaces).toEqual(true); - }); }); describe('UPDATE_TEMP_FLAG', () => { @@ -265,16 +247,6 @@ describe('Multi-file store mutations', () => { expect(localState.changedFiles).toEqual([]); expect(localState.stagedFiles).toEqual([]); }); - - it('bursts unused seal', () => { - localState.entries.test = file('test'); - - expect(localState.unusedSeal).toBe(true); - - mutations.DELETE_ENTRY(localState, 'test'); - - expect(localState.unusedSeal).toBe(false); - }); }); describe('UPDATE_FILE_AFTER_COMMIT', () => { @@ -283,10 +255,6 @@ describe('Multi-file store mutations', () => { ...file('test'), prevPath: 'testing-123', rawPath: `${TEST_HOST}/testing-123`, - permalink: `${TEST_HOST}/testing-123`, - commitsPath: `${TEST_HOST}/testing-123`, - blamePath: `${TEST_HOST}/testing-123`, - replaces: true, }; localState.entries.test = f; localState.changedFiles.push(f); @@ -301,10 +269,6 @@ describe('Multi-file store mutations', () => { expect(f).toEqual( expect.objectContaining({ rawPath: `${TEST_HOST}/test`, - permalink: `${TEST_HOST}/test`, - commitsPath: `${TEST_HOST}/test`, - blamePath: `${TEST_HOST}/test`, - replaces: false, prevId: undefined, prevPath: undefined, prevName: undefined, diff --git a/spec/frontend/ide/stores/plugins/terminal_spec.js b/spec/frontend/ide/stores/plugins/terminal_spec.js new file mode 100644 index 00000000000..948c2131fd8 --- /dev/null +++ b/spec/frontend/ide/stores/plugins/terminal_spec.js @@ -0,0 +1,58 @@ +import { createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { TEST_HOST } from 'helpers/test_constants'; +import terminalModule from '~/ide/stores/modules/terminal'; +import createTerminalPlugin from '~/ide/stores/plugins/terminal'; +import { SET_BRANCH_WORKING_REFERENCE } from '~/ide/stores/mutation_types'; + +const TEST_DATASET = { + eeWebTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`, + eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`, + eeWebTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`, + eeWebTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`, +}; +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ide/stores/extend', () => { + let store; + + beforeEach(() => { + const el = document.createElement('div'); + Object.assign(el.dataset, TEST_DATASET); + + store = new Vuex.Store({ + mutations: { + [SET_BRANCH_WORKING_REFERENCE]: () => {}, + }, + }); + + jest.spyOn(store, 'registerModule').mockImplementation(); + jest.spyOn(store, 'dispatch').mockImplementation(); + + const plugin = createTerminalPlugin(el); + + plugin(store); + }); + + it('registers terminal module', () => { + expect(store.registerModule).toHaveBeenCalledWith('terminal', terminalModule()); + }); + + it('dispatches terminal/setPaths', () => { + expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', { + webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath, + webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath, + webTerminalConfigHelpPath: TEST_DATASET.eeWebTerminalConfigHelpPath, + webTerminalRunnersHelpPath: TEST_DATASET.eeWebTerminalRunnersHelpPath, + }); + }); + + it(`dispatches terminal/init on ${SET_BRANCH_WORKING_REFERENCE}`, () => { + store.dispatch.mockReset(); + + store.commit(SET_BRANCH_WORKING_REFERENCE); + + expect(store.dispatch).toHaveBeenCalledWith('terminal/init'); + }); +}); diff --git a/spec/frontend/ide/stores/plugins/terminal_sync_spec.js b/spec/frontend/ide/stores/plugins/terminal_sync_spec.js new file mode 100644 index 00000000000..2aa3e770e7d --- /dev/null +++ b/spec/frontend/ide/stores/plugins/terminal_sync_spec.js @@ -0,0 +1,72 @@ +import createTerminalPlugin from '~/ide/stores/plugins/terminal'; +import createTerminalSyncPlugin from '~/ide/stores/plugins/terminal_sync'; +import { SET_SESSION_STATUS } from '~/ide/stores/modules/terminal/mutation_types'; +import { RUNNING, STOPPING } from '~/ide/stores/modules/terminal/constants'; +import { createStore } from '~/ide/stores'; +import eventHub from '~/ide/eventhub'; + +jest.mock('~/ide/lib/mirror'); + +const ACTION_START = 'terminalSync/start'; +const ACTION_STOP = 'terminalSync/stop'; +const ACTION_UPLOAD = 'terminalSync/upload'; +const FILES_CHANGE_EVENT = 'ide.files.change'; + +describe('IDE stores/plugins/mirror', () => { + let store; + + beforeEach(() => { + const root = document.createElement('div'); + + store = createStore(); + createTerminalPlugin(root)(store); + + store.dispatch = jest.fn(() => Promise.resolve()); + + createTerminalSyncPlugin(root)(store); + }); + + it('does nothing on ide.files.change event', () => { + eventHub.$emit(FILES_CHANGE_EVENT); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + describe('when session starts running', () => { + beforeEach(() => { + store.commit(`terminal/${SET_SESSION_STATUS}`, RUNNING); + }); + + it('starts', () => { + expect(store.dispatch).toHaveBeenCalledWith(ACTION_START); + }); + + it('uploads when ide.files.change is emitted', () => { + expect(store.dispatch).not.toHaveBeenCalledWith(ACTION_UPLOAD); + + eventHub.$emit(FILES_CHANGE_EVENT); + + jest.runAllTimers(); + + expect(store.dispatch).toHaveBeenCalledWith(ACTION_UPLOAD); + }); + + describe('when session stops', () => { + beforeEach(() => { + store.commit(`terminal/${SET_SESSION_STATUS}`, STOPPING); + }); + + it('stops', () => { + expect(store.dispatch).toHaveBeenCalledWith(ACTION_STOP); + }); + + it('does not upload anymore', () => { + eventHub.$emit(FILES_CHANGE_EVENT); + + jest.runAllTimers(); + + expect(store.dispatch).not.toHaveBeenCalledWith(ACTION_UPLOAD); + }); + }); + }); +}); diff --git a/spec/frontend/ide/stores/utils_spec.js b/spec/frontend/ide/stores/utils_spec.js index b87f6c1f05a..d1eb4304c79 100644 --- a/spec/frontend/ide/stores/utils_spec.js +++ b/spec/frontend/ide/stores/utils_spec.js @@ -28,61 +28,6 @@ describe('Multi-file store utils', () => { }); }); - describe('findIndexOfFile', () => { - let localState; - - beforeEach(() => { - localState = [ - { - path: '1', - }, - { - path: '2', - }, - ]; - }); - - it('finds in the index of an entry by path', () => { - const index = utils.findIndexOfFile(localState, { - path: '2', - }); - - expect(index).toBe(1); - }); - }); - - describe('findEntry', () => { - let localState; - - beforeEach(() => { - localState = { - tree: [ - { - type: 'tree', - name: 'test', - }, - { - type: 'blob', - name: 'file', - }, - ], - }; - }); - - it('returns an entry found by name', () => { - const foundEntry = utils.findEntry(localState.tree, 'tree', 'test'); - - expect(foundEntry.type).toBe('tree'); - expect(foundEntry.name).toBe('test'); - }); - - it('returns undefined when no entry found', () => { - const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); - - expect(foundEntry).toBeUndefined(); - }); - }); - describe('createCommitPayload', () => { it('returns API payload', () => { const state = { @@ -101,12 +46,11 @@ describe('Multi-file store utils', () => { path: 'added', tempFile: true, content: 'new file content', - base64: true, + rawPath: 'data:image/png;base64,abc', lastCommitSha: '123456789', }, { ...file('deletedFile'), path: 'deletedFile', deleted: true }, { ...file('renamedFile'), path: 'renamedFile', prevPath: 'prevPath' }, - { ...file('replacingFile'), path: 'replacingFile', replaces: true }, ], currentBranchId: 'master', }; @@ -154,14 +98,6 @@ describe('Multi-file store utils', () => { last_commit_id: undefined, previous_path: 'prevPath', }, - { - action: commitActionTypes.update, - file_path: 'replacingFile', - content: undefined, - encoding: 'text', - last_commit_id: undefined, - previous_path: undefined, - }, ], start_sha: undefined, }); @@ -181,7 +117,7 @@ describe('Multi-file store utils', () => { path: 'added', tempFile: true, content: 'new file content', - base64: true, + rawPath: 'data:image/png;base64,abc', lastCommitSha: '123456789', }, ], @@ -661,31 +597,6 @@ describe('Multi-file store utils', () => { }); }); - describe('addFinalNewlineIfNeeded', () => { - it('adds a newline if it doesnt already exist', () => { - [ - { - input: 'some text', - output: 'some text\n', - }, - { - input: 'some text\n', - output: 'some text\n', - }, - { - input: 'some text\n\n', - output: 'some text\n\n', - }, - { - input: 'some\n text', - output: 'some\n text\n', - }, - ].forEach(({ input, output }) => { - expect(utils.addFinalNewlineIfNeeded(input)).toEqual(output); - }); - }); - }); - describe('extractMarkdownImagesFromEntries', () => { let mdFile; let entries; diff --git a/spec/frontend/ide/sync_router_and_store_spec.js b/spec/frontend/ide/sync_router_and_store_spec.js new file mode 100644 index 00000000000..c4ce92b99cc --- /dev/null +++ b/spec/frontend/ide/sync_router_and_store_spec.js @@ -0,0 +1,150 @@ +import VueRouter from 'vue-router'; +import { createStore } from '~/ide/stores'; +import { syncRouterAndStore } from '~/ide/sync_router_and_store'; +import waitForPromises from 'helpers/wait_for_promises'; + +const TEST_ROUTE = '/test/lorem/ipsum'; + +describe('~/ide/sync_router_and_store', () => { + let unsync; + let router; + let store; + let onRouterChange; + + const createSync = () => { + unsync = syncRouterAndStore(router, store); + }; + + const getRouterCurrentPath = () => router.currentRoute.fullPath; + const getStoreCurrentPath = () => store.state.router.fullPath; + const updateRouter = path => { + router.push(path); + return waitForPromises(); + }; + const updateStore = path => { + store.dispatch('router/push', path); + return waitForPromises(); + }; + + beforeEach(() => { + router = new VueRouter(); + store = createStore(); + jest.spyOn(store, 'dispatch'); + + onRouterChange = jest.fn(); + router.beforeEach((to, from, next) => { + onRouterChange(to, from); + next(); + }); + }); + + afterEach(() => { + unsync(); + unsync = null; + }); + + it('keeps store and router in sync', async () => { + createSync(); + + await updateRouter('/test/test'); + await updateRouter('/test/test'); + await updateStore('123/abc'); + await updateRouter('def'); + + // Even though we pused relative paths, the store and router kept track of the resulting fullPath + expect(getRouterCurrentPath()).toBe('/test/123/def'); + expect(getStoreCurrentPath()).toBe('/test/123/def'); + }); + + describe('default', () => { + beforeEach(() => { + createSync(); + }); + + it('store is default', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + expect(getStoreCurrentPath()).toBe(''); + }); + + it('router is default', () => { + expect(onRouterChange).not.toHaveBeenCalled(); + expect(getRouterCurrentPath()).toBe('/'); + }); + + describe('when store changes', () => { + beforeEach(() => { + updateStore(TEST_ROUTE); + }); + + it('store is updated', () => { + // let's make sure the action isn't dispatched more than necessary + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(getStoreCurrentPath()).toBe(TEST_ROUTE); + }); + + it('router is updated', () => { + expect(onRouterChange).toHaveBeenCalledTimes(1); + expect(getRouterCurrentPath()).toBe(TEST_ROUTE); + }); + + describe('when store changes again to the same thing', () => { + beforeEach(() => { + onRouterChange.mockClear(); + updateStore(TEST_ROUTE); + }); + + it('doesnt change router again', () => { + expect(onRouterChange).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when router changes', () => { + beforeEach(() => { + updateRouter(TEST_ROUTE); + }); + + it('store is updated', () => { + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(getStoreCurrentPath()).toBe(TEST_ROUTE); + }); + + it('router is updated', () => { + // let's make sure the router change isn't triggered more than necessary + expect(onRouterChange).toHaveBeenCalledTimes(1); + expect(getRouterCurrentPath()).toBe(TEST_ROUTE); + }); + + describe('when router changes again to the same thing', () => { + beforeEach(() => { + store.dispatch.mockClear(); + updateRouter(TEST_ROUTE); + }); + + it('doesnt change store again', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when disposed', () => { + beforeEach(() => { + unsync(); + }); + + it('a store change does not trigger a router change', () => { + updateStore(TEST_ROUTE); + + expect(getRouterCurrentPath()).toBe('/'); + expect(onRouterChange).not.toHaveBeenCalled(); + }); + + it('a router change does not trigger a store change', () => { + updateRouter(TEST_ROUTE); + + expect(getStoreCurrentPath()).toBe(''); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index ea975500e8d..15baeca7f36 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -1,6 +1,13 @@ -import { commitItemIconMap } from '~/ide/constants'; -import { getCommitIconMap, isTextFile, registerLanguages, trimPathComponents } from '~/ide/utils'; -import { decorateData } from '~/ide/stores/utils'; +import { + isTextFile, + registerLanguages, + trimPathComponents, + insertFinalNewline, + trimTrailingWhitespace, + getPathParents, + getPathParent, + readFileAsDataURL, +} from '~/ide/utils'; import { languages } from 'monaco-editor'; describe('WebIDE utils', () => { @@ -62,48 +69,6 @@ describe('WebIDE utils', () => { }); }); - const createFile = (name = 'name', id = name, type = '', parent = null) => - decorateData({ - id, - type, - icon: 'icon', - url: 'url', - name, - path: parent ? `${parent.path}/${name}` : name, - parentPath: parent ? parent.path : '', - lastCommit: {}, - }); - - describe('getCommitIconMap', () => { - let entry; - - beforeEach(() => { - entry = createFile('Entry item'); - }); - - it('renders "deleted" icon for deleted entries', () => { - entry.deleted = true; - expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.deleted); - }); - - it('renders "addition" icon for temp entries', () => { - entry.tempFile = true; - expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.addition); - }); - - it('renders "modified" icon for newly-renamed entries', () => { - entry.prevPath = 'foo/bar'; - entry.tempFile = false; - expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); - }); - - it('renders "modified" icon even for temp entries if they are newly-renamed', () => { - entry.prevPath = 'foo/bar'; - entry.tempFile = true; - expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified); - }); - }); - describe('trimPathComponents', () => { it.each` input | output @@ -192,4 +157,86 @@ describe('WebIDE utils', () => { ]); }); }); + + describe('trimTrailingWhitespace', () => { + it.each` + input | output + ${'text \n more text \n'} | ${'text\n more text\n'} + ${'text \n more text \n\n \n'} | ${'text\n more text\n\n\n'} + ${'text \t\t \n more text \n\t\ttext\n \n\t\t'} | ${'text\n more text\n\t\ttext\n\n'} + ${'text \r\n more text \r\n'} | ${'text\r\n more text\r\n'} + ${'text \r\n more text \r\n\r\n \r\n'} | ${'text\r\n more text\r\n\r\n\r\n'} + ${'text \t\t \r\n more text \r\n\t\ttext\r\n \r\n\t\t'} | ${'text\r\n more text\r\n\t\ttext\r\n\r\n'} + `("trims trailing whitespace in each line of file's contents: $input", ({ input, output }) => { + expect(trimTrailingWhitespace(input)).toBe(output); + }); + }); + + describe('addFinalNewline', () => { + it.each` + input | output + ${'some text'} | ${'some text\n'} + ${'some text\n'} | ${'some text\n'} + ${'some text\n\n'} | ${'some text\n\n'} + ${'some\n text'} | ${'some\n text\n'} + `('adds a newline if it doesnt already exist for input: $input', ({ input, output }) => { + expect(insertFinalNewline(input)).toBe(output); + }); + + it.each` + input | output + ${'some text'} | ${'some text\r\n'} + ${'some text\r\n'} | ${'some text\r\n'} + ${'some text\n'} | ${'some text\n\r\n'} + ${'some text\r\n\r\n'} | ${'some text\r\n\r\n'} + ${'some\r\n text'} | ${'some\r\n text\r\n'} + `('works with CRLF newline style; input: $input', ({ input, output }) => { + expect(insertFinalNewline(input, '\r\n')).toBe(output); + }); + }); + + describe('getPathParents', () => { + it.each` + path | parents + ${'foo/bar/baz/index.md'} | ${['foo/bar/baz', 'foo/bar', 'foo']} + ${'foo/bar/baz'} | ${['foo/bar', 'foo']} + ${'index.md'} | ${[]} + ${'path with/spaces to/something.md'} | ${['path with/spaces to', 'path with']} + `('gets all parent directory names for path: $path', ({ path, parents }) => { + expect(getPathParents(path)).toEqual(parents); + }); + + it.each` + path | depth | parents + ${'foo/bar/baz/index.md'} | ${0} | ${[]} + ${'foo/bar/baz/index.md'} | ${1} | ${['foo/bar/baz']} + ${'foo/bar/baz/index.md'} | ${2} | ${['foo/bar/baz', 'foo/bar']} + ${'foo/bar/baz/index.md'} | ${3} | ${['foo/bar/baz', 'foo/bar', 'foo']} + ${'foo/bar/baz/index.md'} | ${4} | ${['foo/bar/baz', 'foo/bar', 'foo']} + `('gets only the immediate $depth parents if when depth=$depth', ({ path, depth, parents }) => { + expect(getPathParents(path, depth)).toEqual(parents); + }); + }); + + describe('getPathParent', () => { + it.each` + path | parents + ${'foo/bar/baz/index.md'} | ${'foo/bar/baz'} + ${'foo/bar/baz'} | ${'foo/bar'} + ${'index.md'} | ${undefined} + ${'path with/spaces to/something.md'} | ${'path with/spaces to'} + `('gets the immediate parent for path: $path', ({ path, parents }) => { + expect(getPathParent(path)).toEqual(parents); + }); + }); + + describe('readFileAsDataURL', () => { + it('reads a file and returns its output as a data url', () => { + const file = new File(['foo'], 'foo.png', { type: 'image/png' }); + + return readFileAsDataURL(file).then(contents => { + expect(contents).toBe('data:image/png;base64,Zm9v'); + }); + }); + }); }); |