diff options
Diffstat (limited to 'spec/frontend/ide/components')
27 files changed, 2367 insertions, 185 deletions
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); + }); + } + }); +}); |