Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/ide')
-rw-r--r--spec/frontend/ide/commit_icon_spec.js45
-rw-r--r--spec/frontend/ide/components/branches/item_spec.js11
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js136
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_item_spec.js13
-rw-r--r--spec/frontend/ide/components/commit_sidebar/message_field_spec.js170
-rw-r--r--spec/frontend/ide/components/ide_sidebar_nav_spec.js118
-rw-r--r--spec/frontend/ide/components/ide_spec.js9
-rw-r--r--spec/frontend/ide/components/ide_status_list_spec.js16
-rw-r--r--spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap4
-rw-r--r--spec/frontend/ide/components/jobs/detail_spec.js187
-rw-r--r--spec/frontend/ide/components/merge_requests/item_spec.js106
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js40
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js2
-rw-r--r--spec/frontend/ide/components/panes/collapsible_sidebar_spec.js113
-rw-r--r--spec/frontend/ide/components/panes/right_spec.js57
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js2
-rw-r--r--spec/frontend/ide/components/repo_commit_section_spec.js46
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js664
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js12
-rw-r--r--spec/frontend/ide/components/repo_tabs_spec.js2
-rw-r--r--spec/frontend/ide/components/resizable_panel_spec.js114
-rw-r--r--spec/frontend/ide/components/terminal/empty_state_spec.js107
-rw-r--r--spec/frontend/ide/components/terminal/session_spec.js96
-rw-r--r--spec/frontend/ide/components/terminal/terminal_controls_spec.js65
-rw-r--r--spec/frontend/ide/components/terminal/terminal_spec.js225
-rw-r--r--spec/frontend/ide/components/terminal/view_spec.js91
-rw-r--r--spec/frontend/ide/components/terminal_sync/terminal_sync_status_safe_spec.js47
-rw-r--r--spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js99
-rw-r--r--spec/frontend/ide/file_helpers.js35
-rw-r--r--spec/frontend/ide/ide_router_spec.js37
-rw-r--r--spec/frontend/ide/lib/common/model_spec.js72
-rw-r--r--spec/frontend/ide/lib/create_diff_spec.js182
-rw-r--r--spec/frontend/ide/lib/create_file_diff_spec.js163
-rw-r--r--spec/frontend/ide/lib/diff/diff_spec.js8
-rw-r--r--spec/frontend/ide/lib/editor_options_spec.js11
-rw-r--r--spec/frontend/ide/lib/editor_spec.js46
-rw-r--r--spec/frontend/ide/lib/editorconfig/mock_data.js146
-rw-r--r--spec/frontend/ide/lib/editorconfig/parser_spec.js18
-rw-r--r--spec/frontend/ide/lib/editorconfig/rules_mapper_spec.js43
-rw-r--r--spec/frontend/ide/lib/files_spec.js4
-rw-r--r--spec/frontend/ide/lib/mirror_spec.js184
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js40
-rw-r--r--spec/frontend/ide/stores/actions/merge_request_spec.js504
-rw-r--r--spec/frontend/ide/stores/actions/project_spec.js397
-rw-r--r--spec/frontend/ide/stores/actions/tree_spec.js218
-rw-r--r--spec/frontend/ide/stores/actions_spec.js1062
-rw-r--r--spec/frontend/ide/stores/extend_spec.js74
-rw-r--r--spec/frontend/ide/stores/getters_spec.js65
-rw-r--r--spec/frontend/ide/stores/modules/commit/actions_spec.js598
-rw-r--r--spec/frontend/ide/stores/modules/pane/getters_spec.js32
-rw-r--r--spec/frontend/ide/stores/modules/router/actions_spec.js19
-rw-r--r--spec/frontend/ide/stores/modules/router/mutations_spec.js23
-rw-r--r--spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js289
-rw-r--r--spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js300
-rw-r--r--spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js169
-rw-r--r--spec/frontend/ide/stores/modules/terminal/actions/setup_spec.js40
-rw-r--r--spec/frontend/ide/stores/modules/terminal/getters_spec.js50
-rw-r--r--spec/frontend/ide/stores/modules/terminal/messages_spec.js38
-rw-r--r--spec/frontend/ide/stores/modules/terminal/mutations_spec.js142
-rw-r--r--spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js118
-rw-r--r--spec/frontend/ide/stores/modules/terminal_sync/mutations_spec.js89
-rw-r--r--spec/frontend/ide/stores/mutations/file_spec.js37
-rw-r--r--spec/frontend/ide/stores/mutations_spec.js36
-rw-r--r--spec/frontend/ide/stores/plugins/terminal_spec.js58
-rw-r--r--spec/frontend/ide/stores/plugins/terminal_sync_spec.js72
-rw-r--r--spec/frontend/ide/stores/utils_spec.js93
-rw-r--r--spec/frontend/ide/sync_router_and_store_spec.js150
-rw-r--r--spec/frontend/ide/utils_spec.js137
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');
+ });
+ });
+ });
});