Commit ceed1934 authored by Denys Mishunov's avatar Denys Mishunov Committed by David O'Regan

Converted WebIDE into a proper EL extension

- Create new instance on view change
- Moved modelManager to RepoEditor
- Do not create new instance for every file
- Removed DirtyDiffController from the extension
- Removed DecorationsController
- Removed un-used-anymore currentModel
- Dispose editor only when editor exists
- Holistic handling of the model attachment
parent 81b2ae7f
...@@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready'; ...@@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
// //
// EXTENSIONS' CONSTANTS // EXTENSIONS' CONSTANTS
// //
......
import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base';
import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json';
const isDiffEditorType = (instance) => {
return instance.getEditorType() === EDITOR_TYPE_DIFF;
};
export const UPDATE_DIMENSIONS_DELAY = 200;
export class EditorWebIdeExtension extends EditorLiteExtension {
constructor({ instance, modelManager, ...options } = {}) {
super({
instance,
...options,
modelManager,
disposable: new Disposable(),
debouncedUpdate: debounce(() => {
instance.updateDimensions();
}, UPDATE_DIMENSIONS_DELAY),
});
window.addEventListener('resize', instance.debouncedUpdate, false);
instance.onDidDispose(() => {
window.removeEventListener('resize', instance.debouncedUpdate);
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
instance.disposable.dispose();
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.error(e);
}
}
});
EditorWebIdeExtension.addActions(instance);
}
static addActions(instance) {
const { store } = instance;
const getKeyCode = (key) => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? KeyCode[key] : KeyMod[key];
};
keymap.forEach((command) => {
const { bindings, id, label, action } = command;
const keybindings = bindings.map((binding) => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
instance.addAction({
id,
label,
keybindings,
run() {
store.dispatch(action.name, action.params);
return null;
},
});
});
}
createModel(file, head = null) {
return this.modelManager.addModel(file, head);
}
attachModel(model) {
if (isDiffEditorType(this)) {
this.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
});
return;
}
this.setModel(model.getModel());
this.updateOptions(
editorOptions.reduce((acc, obj) => {
Object.keys(obj).forEach((key) => {
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}),
);
}
attachMergeRequestModel(model) {
this.setModel({
original: model.getBaseModel(),
modified: model.getModel(),
});
}
updateDimensions() {
this.layout();
this.updateDiffView();
}
setPos({ lineNumber, column }) {
this.revealPositionInCenter({
lineNumber,
column,
});
this.setPosition({
lineNumber,
column,
});
}
onPositionChange(cb) {
if (!this.onDidChangeCursorPosition) {
return;
}
this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e)));
}
updateDiffView() {
if (!isDiffEditorType(this)) {
return;
}
this.updateOptions({
renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
});
}
replaceSelectedText(text) {
let selection = this.getSelection();
const range = new Range(
selection.startLineNumber,
selection.startColumn,
selection.endLineNumber,
selection.endColumn,
);
this.executeEdits('', [{ range, text }]);
selection = this.getSelection();
this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
}
static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700;
}
}
...@@ -49,7 +49,9 @@ export default { ...@@ -49,7 +49,9 @@ export default {
</script> </script>
<template> <template>
<div class="d-flex align-items-center ide-file-templates qa-file-templates-bar"> <div
class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
>
<strong class="gl-mr-3"> {{ __('File templates') }} </strong> <strong class="gl-mr-3"> {{ __('File templates') }} </strong>
<dropdown <dropdown
:data="templateTypes" :data="templateTypes"
......
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
EDITOR_CODE_INSTANCE_FN,
EDITOR_DIFF_INSTANCE_FN,
} from '~/editor/constants';
import EditorLite from '~/editor/editor_lite';
import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import { deprecatedCreateFlash as flash } from '~/flash'; import { deprecatedCreateFlash as flash } from '~/flash';
import ModelManager from '~/ide/lib/common/model_manager';
import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { import {
WEBIDE_MARK_FILE_CLICKED, WEBIDE_MARK_FILE_CLICKED,
...@@ -20,7 +29,6 @@ import { ...@@ -20,7 +29,6 @@ import {
FILE_VIEW_MODE_PREVIEW, FILE_VIEW_MODE_PREVIEW,
} from '../constants'; } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import Editor from '../lib/editor';
import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
...@@ -46,6 +54,9 @@ export default { ...@@ -46,6 +54,9 @@ export default {
content: '', content: '',
images: {}, images: {},
rules: {}, rules: {},
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
}; };
}, },
computed: { computed: {
...@@ -132,6 +143,7 @@ export default { ...@@ -132,6 +143,7 @@ export default {
// Compare key to allow for files opened in review mode to be cached differently // Compare key to allow for files opened in review mode to be cached differently
if (oldVal.key !== this.file.key) { if (oldVal.key !== this.file.key) {
this.isEditorLoading = true;
this.initEditor(); this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) { if (this.currentActivityView !== leftSidebarViews.edit.name) {
...@@ -149,6 +161,7 @@ export default { ...@@ -149,6 +161,7 @@ export default {
} }
}, },
viewer() { viewer() {
this.isEditorLoading = false;
if (!this.file.pending) { if (!this.file.pending) {
this.createEditorInstance(); this.createEditorInstance();
} }
...@@ -181,11 +194,11 @@ export default { ...@@ -181,11 +194,11 @@ export default {
}, },
}, },
beforeDestroy() { beforeDestroy() {
this.editor.dispose(); this.globalEditor.dispose();
}, },
mounted() { mounted() {
if (!this.editor) { if (!this.globalEditor) {
this.editor = Editor.create(this.$store, this.editorOptions); this.globalEditor = new EditorLite();
} }
this.initEditor(); this.initEditor();
...@@ -211,8 +224,6 @@ export default { ...@@ -211,8 +224,6 @@ export default {
return; return;
} }
this.editor.clearEditor();
this.registerSchemaForFile(); this.registerSchemaForFile();
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
...@@ -251,20 +262,45 @@ export default { ...@@ -251,20 +262,45 @@ export default {
return; return;
} }
this.editor.dispose(); const isDiff = this.viewer !== viewerTypes.edit;
const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF);
this.$nextTick(() => { if (this.editor && !shouldDisposeEditor) {
if (this.viewer === viewerTypes.edit) { this.setupEditor();
this.editor.createInstance(this.$refs.editor); } else {
} else { if (this.editor && shouldDisposeEditor) {
this.editor.createDiffInstance(this.$refs.editor); this.editor.dispose();
} }
const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
this.setupEditor(); this.editor = this.globalEditor[method]({
}); el: this.$refs.editor,
blobPath: this.file.path,
blobGlobalId: this.file.key,
blobContent: this.content || this.file.content,
...instanceOptions,
...this.editorOptions,
});
this.editor.use(
new EditorWebIdeExtension({
instance: this.editor,
modelManager: this.modelManager,
store: this.$store,
file: this.file,
options: this.editorOptions,
}),
);
this.$nextTick(() => {
this.setupEditor();
});
}
}, },
setupEditor() { setupEditor() {
if (!this.file || !this.editor.instance || this.file.loading) return; if (!this.file || !this.editor || this.file.loading) return;
const head = this.getStagedFile(this.file.path); const head = this.getStagedFile(this.file.path);
...@@ -279,6 +315,8 @@ export default { ...@@ -279,6 +315,8 @@ export default {
this.editor.attachModel(this.model); this.editor.attachModel(this.model);
} }
this.isEditorLoading = false;
this.model.updateOptions(this.rules); this.model.updateOptions(this.rules);
this.model.onChange((model) => { this.model.onChange((model) => {
...@@ -298,7 +336,7 @@ export default { ...@@ -298,7 +336,7 @@ export default {
}); });
}); });
this.editor.setPosition({ this.editor.setPos({
lineNumber: this.fileEditor.editorRow, lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn, column: this.fileEditor.editorColumn,
}); });
...@@ -308,6 +346,10 @@ export default { ...@@ -308,6 +346,10 @@ export default {
fileLanguage: this.model.language, fileLanguage: this.model.language,
}); });
this.$nextTick(() => {
this.editor.updateDimensions();
});
this.$emit('editorSetup'); this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
...@@ -344,7 +386,7 @@ export default { ...@@ -344,7 +386,7 @@ export default {
}); });
}, },
onPaste(event) { onPaste(event) {
const editor = this.editor.instance; const { editor } = this;
const reImage = /^image\/(png|jpg|jpeg|gif)$/; const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0]; const file = event.clipboardData.files[0];
...@@ -395,6 +437,7 @@ export default { ...@@ -395,6 +437,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
> >
{{ __('Edit') }} {{ __('Edit') }}
...@@ -404,6 +447,7 @@ export default { ...@@ -404,6 +447,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
data-testid="preview-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a >{{ previewMode.previewTitle }}</a
> >
...@@ -414,6 +458,7 @@ export default { ...@@ -414,6 +458,7 @@ export default {
<div <div
v-show="showEditor" v-show="showEditor"
ref="editor" ref="editor"
:key="`content-editor`"
:class="{ :class="{
'is-readonly': isCommitModeActive, 'is-readonly': isCommitModeActive,
'is-deleted': file.deleted, 'is-deleted': file.deleted,
...@@ -421,6 +466,8 @@ export default { ...@@ -421,6 +466,8 @@ export default {
}" }"
class="multi-file-editor-holder" class="multi-file-editor-holder"
data-qa-selector="editor_container" data-qa-selector="editor_container"
data-testid="editor-container"
:data-editor-loading="isEditorLoading"
@focusout="triggerFilesChange" @focusout="triggerFilesChange"
></div> ></div>
<content-viewer <content-viewer
......
...@@ -3,6 +3,11 @@ ...@@ -3,6 +3,11 @@
@include gl-display-flex; @include gl-display-flex;
@include gl-justify-content-center; @include gl-justify-content-center;
@include gl-align-items-center; @include gl-align-items-center;
@include gl-z-index-0;
> * {
filter: blur(5px);
}
&::before { &::before {
content: ''; content: '';
......
---
title: Introduce WebIDE as an extension for Editor Lite
merge_request: 51527
author:
type: changed
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { Range } from 'monaco-editor'; import { editor as monacoEditor, Range } from 'monaco-editor';
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import '~/behaviors/markdown/render_gfm'; import '~/behaviors/markdown/render_gfm';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import waitUsingRealTimer from 'helpers/wait_using_real_timer';
import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data';
import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants';
import EditorLite from '~/editor/editor_lite';
import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import RepoEditor from '~/ide/components/repo_editor.vue'; import RepoEditor from '~/ide/components/repo_editor.vue';
import { import {
leftSidebarViews, leftSidebarViews,
...@@ -13,733 +17,723 @@ import { ...@@ -13,733 +17,723 @@ import {
FILE_VIEW_MODE_PREVIEW, FILE_VIEW_MODE_PREVIEW,
viewerTypes, viewerTypes,
} from '~/ide/constants'; } from '~/ide/constants';
import Editor from '~/ide/lib/editor'; import ModelManager from '~/ide/lib/common/model_manager';
import service from '~/ide/services'; import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores'; import { createStoreOptions } from '~/ide/stores';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { file } from '../helpers'; import { file } from '../helpers';
import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data';
const defaultFileProps = {
...file('file.txt'),
content: 'hello world',
active: true,
tempFile: true,
};
const createActiveFile = (props) => {
return {
...defaultFileProps,
...props,
};
};
const dummyFile = {
markdown: (() =>
createActiveFile({
projectId: 'namespace/project',
path: 'sample.md',
name: 'sample.md',
}))(),
binary: (() =>
createActiveFile({
name: 'file.dat',
content: '🐱', // non-ascii binary content,
}))(),
empty: (() =>
createActiveFile({
tempFile: false,
content: '',
raw: '',
}))(),
};
const prepareStore = (state, activeFile) => {
const localState = {
openFiles: [activeFile],
projects: {
'gitlab-org/gitlab': {
branches: {
master: {
name: 'master',
commit: {
id: 'abcdefgh',
},
},
},
},
},
currentProjectId: 'gitlab-org/gitlab',
currentBranchId: 'master',
entries: {
[activeFile.path]: activeFile,
},
};
const storeOptions = createStoreOptions();
return new Vuex.Store({
...createStoreOptions(),
state: {
...storeOptions.state,
...localState,
...state,
},
});
};
describe('RepoEditor', () => { describe('RepoEditor', () => {
let wrapper;
let vm; let vm;
let store; let createInstanceSpy;
let createDiffInstanceSpy;
let createModelSpy;
const waitForEditorSetup = () => const waitForEditorSetup = () =>
new Promise((resolve) => { new Promise((resolve) => {
vm.$once('editorSetup', resolve); vm.$once('editorSetup', resolve);
}); });
const createComponent = () => { const createComponent = async ({ state = {}, activeFile = defaultFileProps } = {}) => {
if (vm) { const store = prepareStore(state, activeFile);
throw new Error('vm already exists'); wrapper = shallowMount(RepoEditor, {
} store,
vm = createComponentWithStore(Vue.extend(RepoEditor), store, { propsData: {
file: store.state.openFiles[0], file: store.state.openFiles[0],
},
mocks: {
ContentViewer,
},
}); });
await waitForPromises();
vm = wrapper.vm;
jest.spyOn(vm, 'getFileData').mockResolvedValue(); jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue(); jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
vm.$mount();
}; };
const createOpenFile = (path) => { const findEditor = () => wrapper.find('[data-testid="editor-container"]');
const origFile = store.state.openFiles[0]; const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' }; const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
store.state.entries[path] = newFile;
store.state.openFiles = [newFile];
};
beforeEach(() => { beforeEach(() => {
const f = { createInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_CODE_INSTANCE_FN);
...file('file.txt'), createDiffInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_DIFF_INSTANCE_FN);
content: 'hello world', createModelSpy = jest.spyOn(monacoEditor, 'createModel');
}; jest.spyOn(service, 'getFileData').mockResolvedValue();
jest.spyOn(service, 'getRawFileData').mockResolvedValue();
const storeOptions = createStoreOptions();
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(() => { afterEach(() => {
vm.$destroy(); jest.clearAllMocks();
vm = null; // create a new model each time, otherwise tests conflict with each other
// because of same model being used in multiple tests
Editor.editorInstance.dispose(); // eslint-disable-next-line no-undef
monaco.editor.getModels().forEach((model) => model.dispose());
wrapper.destroy();
wrapper = null;
}); });
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
const changeViewMode = (viewMode) =>
store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } });
describe('default', () => { describe('default', () => {
beforeEach(() => { it.each`
createComponent(); boolVal | textVal
${true} | ${'all'}
return waitForEditorSetup(); ${false} | ${'none'}
`('sets renderWhitespace to "$textVal"', async ({ boolVal, textVal } = {}) => {
await createComponent({
state: {
renderWhitespaceInCode: boolVal,
},
});
expect(vm.editorOptions.renderWhitespace).toEqual(textVal);
}); });
it('sets renderWhitespace to `all`', () => { it('renders an ide container', async () => {
vm.$store.state.renderWhitespaceInCode = true; await createComponent();
expect(findEditor().isVisible()).toBe(true);
expect(vm.editorOptions.renderWhitespace).toEqual('all');
}); });
it('sets renderWhitespace to `none`', () => { it('renders only an edit tab', async () => {
vm.$store.state.renderWhitespaceInCode = false; await createComponent();
const tabs = findTabs();
expect(vm.editorOptions.renderWhitespace).toEqual('none'); expect(tabs).toHaveLength(1);
expect(tabs.at(0).text()).toBe('Edit');
}); });
});
it('renders an ide container', () => { describe('when file is markdown', () => {
expect(vm.shouldHideEditor).toBeFalsy(); let mock;
expect(vm.showEditor).toBe(true); let activeFile;
expect(findEditor()).not.toHaveCss({ display: 'none' });
});
it('renders only an edit tab', (done) => { beforeEach(() => {
Vue.nextTick(() => { activeFile = dummyFile.markdown;
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
expect(tabs.length).toBe(1); mock = new MockAdapter(axios);
expect(tabs[0].textContent.trim()).toBe('Edit');
done(); mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: `<p>${defaultFileProps.content}</p>`,
}); });
}); });
describe('when file is markdown', () => { afterEach(() => {
let mock; mock.restore();
});
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',
name: 'sample.md',
content: 'testing 123',
});
vm.$store.state.entries[vm.file.path] = vm.file;
return vm.$nextTick(); it('renders an Edit and a Preview Tab', async () => {
}); await createComponent({ activeFile });
const tabs = findTabs();
afterEach(() => { expect(tabs).toHaveLength(2);
mock.restore(); expect(tabs.at(0).text()).toBe('Edit');
}); expect(tabs.at(1).text()).toBe('Preview Markdown');
});
it('renders an Edit and a Preview Tab', (done) => { it('renders markdown for tempFile', async () => {
Vue.nextTick(() => { // by default files created in the spec are temp: no need for explicitly sending the param
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li'); await createComponent({ activeFile });
expect(tabs.length).toBe(2); findPreviewTab().trigger('click');
expect(tabs[0].textContent.trim()).toBe('Edit'); await waitForPromises();
expect(tabs[1].textContent.trim()).toBe('Preview Markdown'); expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content);
});
done(); it('shows no tabs when not in Edit mode', async () => {
}); await createComponent({
state: {
currentActivityView: leftSidebarViews.review.name,
},
activeFile,
}); });
expect(findTabs()).toHaveLength(0);
});
});
it('renders markdown for tempFile', (done) => { describe('when file is binary and not raw', () => {
vm.file.tempFile = true; beforeEach(async () => {
const activeFile = dummyFile.binary;
vm.$nextTick() await createComponent({ activeFile });
.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', () => { it('does not render the IDE', () => {
beforeEach(async () => { expect(findEditor().isVisible()).toBe(false);
await vm.$nextTick(); });
vm.$store.state.currentActivityView = leftSidebarViews.review.name; it('does not create an instance', () => {
expect(createInstanceSpy).not.toHaveBeenCalled();
expect(createDiffInstanceSpy).not.toHaveBeenCalled();
});
});
return vm.$nextTick(); describe('createEditorInstance', () => {
it.each`
viewer | diffInstance
${viewerTypes.edit} | ${undefined}
${viewerTypes.diff} | ${true}
${viewerTypes.mr} | ${true}
`(
'creates instance of correct type when viewer is $viewer',
async ({ viewer, diffInstance }) => {
await createComponent({
state: { viewer },
}); });
const isDiff = () => {
return diffInstance ? { isDiff: true } : {};
};
expect(createInstanceSpy).toHaveBeenCalledWith(expect.objectContaining(isDiff()));
expect(createDiffInstanceSpy).toHaveBeenCalledTimes((diffInstance && 1) || 0);
},
);
it('shows no tabs', () => { it('installs the WebIDE extension', async () => {
expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0); const extensionSpy = jest.spyOn(EditorLite, 'instanceApplyExtension');
await createComponent();
expect(extensionSpy).toHaveBeenCalled();
Reflect.ownKeys(EditorWebIdeExtension.prototype)
.filter((fn) => fn !== 'constructor')
.forEach((fn) => {
expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]);
}); });
});
}); });
});
describe('when open file is binary and not raw', () => { describe('setupEditor', () => {
beforeEach((done) => { beforeEach(async () => {
vm.file.name = 'file.dat'; await createComponent();
vm.file.content = '🐱'; // non-ascii binary content });
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.$nextTick(done);
});
it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy();
});
it('does not call createInstance', async () => {
// Mirror the act's in the `createEditorInstance`
vm.createEditorInstance();
await vm.$nextTick();
expect(vm.editor.createInstance).not.toHaveBeenCalled(); it('creates new model on load', () => {
expect(vm.editor.createDiffInstance).not.toHaveBeenCalled(); // We always create two models per file to be able to build a diff of changes
}); expect(createModelSpy).toHaveBeenCalledTimes(2);
// The model with the most recent changes is the last one
const [content] = createModelSpy.mock.calls[1];
expect(content).toBe(defaultFileProps.content);
}); });
describe('createEditorInstance', () => { it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', () => {
it('calls createInstance when viewer is editor', (done) => { const existingModel = vm.model;
jest.spyOn(vm.editor, 'createInstance').mockImplementation(); createModelSpy.mockClear();
vm.createEditorInstance(); vm.setupEditor();
vm.$nextTick(() => { expect(createModelSpy).not.toHaveBeenCalled();
expect(vm.editor.createInstance).toHaveBeenCalled(); expect(vm.model).toBe(existingModel);
});
done(); it('adds callback methods', () => {
}); jest.spyOn(vm.editor, 'onPositionChange');
}); jest.spyOn(vm.model, 'onChange');
jest.spyOn(vm.model, 'updateOptions');
it('calls createDiffInstance when viewer is diff', (done) => { vm.setupEditor();
vm.$store.state.viewer = 'diff';
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1);
expect(vm.model.onChange).toHaveBeenCalledTimes(1);
expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules);
});
vm.createEditorInstance(); it('updates state with the value of the model', () => {
const newContent = 'As Gregor Samsa\n awoke one morning\n';
vm.model.setValue(newContent);
vm.$nextTick(() => { vm.setupEditor();
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
done(); expect(vm.file.content).toBe(newContent);
}); });
});
it('calls createDiffInstance when viewer is a merge request diff', (done) => { it('sets head model as staged file', () => {
vm.$store.state.viewer = 'mrdiff'; vm.modelManager.dispose();
const addModelSpy = jest.spyOn(ModelManager.prototype, 'addModel');
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation(); vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
vm.file.staged = true;
vm.file.key = `unstaged-${vm.file.key}`;
vm.createEditorInstance(); vm.setupEditor();
vm.$nextTick(() => { expect(addModelSpy).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
expect(vm.editor.createDiffInstance).toHaveBeenCalled(); });
});
done(); describe('editor updateDimensions', () => {
}); let updateDimensionsSpy;
}); let updateDiffViewSpy;
beforeEach(async () => {
await createComponent();
updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
}); });
describe('setupEditor', () => { it('calls updateDimensions only when panelResizing is false', async () => {
it('creates new model', () => { expect(updateDimensionsSpy).not.toHaveBeenCalled();
jest.spyOn(vm.editor, 'createModel'); expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.panelResizing).toBe(false); // default value
Editor.editorInstance.modelManager.dispose(); vm.$store.state.panelResizing = true;
await vm.$nextTick();
vm.setupEditor(); expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null); vm.$store.state.panelResizing = false;
expect(vm.model).not.toBeNull(); await vm.$nextTick();
});
it('attaches model to editor', () => { expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
jest.spyOn(vm.editor, 'attachModel'); expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
Editor.editorInstance.modelManager.dispose(); vm.$store.state.panelResizing = true;
await vm.$nextTick();
vm.setupEditor(); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
});
expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); it('calls updateDimensions when rightPane is toggled', async () => {
}); expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value
it('attaches model to merge request editor', () => { vm.$store.state.rightPane.isOpen = true;
vm.$store.state.viewer = 'mrdiff'; await vm.$nextTick();
vm.file.mrChange = true;
jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation();
Editor.editorInstance.modelManager.dispose(); expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
vm.setupEditor(); vm.$store.state.rightPane.isOpen = false;
await vm.$nextTick();
expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model); expect(updateDimensionsSpy).toHaveBeenCalledTimes(2);
}); expect(updateDiffViewSpy).toHaveBeenCalledTimes(2);
});
});
it('does not attach model to merge request editor when not a MR change', () => { describe('editor tabs', () => {
vm.$store.state.viewer = 'mrdiff'; beforeEach(async () => {
vm.file.mrChange = false; await createComponent();
jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation(); });
Editor.editorInstance.modelManager.dispose(); it.each`
mode | isVisible
${'edit'} | ${true}
${'review'} | ${false}
${'commit'} | ${false}
`('tabs in $mode are $isVisible', async ({ mode, isVisible } = {}) => {
vm.$store.state.currentActivityView = leftSidebarViews[mode].name;
vm.setupEditor(); await vm.$nextTick();
expect(wrapper.find('.nav-links').exists()).toBe(isVisible);
});
});
expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model); describe('files in preview mode', () => {
let updateDimensionsSpy;
const changeViewMode = (viewMode) =>
vm.$store.dispatch('editor/updateFileEditor', {
path: vm.file.path,
data: { viewMode },
}); });
it('adds callback methods', () => { beforeEach(async () => {
jest.spyOn(vm.editor, 'onPositionChange'); await createComponent({
activeFile: dummyFile.markdown,
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', () => { updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
vm.model.setValue('testing 1234\n');
vm.setupEditor();
expect(vm.file.content).toBe('testing 1234\n');
});
it('sets head model as staged file', () => { changeViewMode(FILE_VIEW_MODE_PREVIEW);
jest.spyOn(vm.editor, 'createModel'); await vm.$nextTick();
});
Editor.editorInstance.modelManager.dispose(); it('do not show the editor', () => {
expect(vm.showEditor).toBe(false);
expect(findEditor().isVisible()).toBe(false);
});
vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' }); it('updates dimensions when switching view back to edit', async () => {
vm.file.staged = true; expect(updateDimensionsSpy).not.toHaveBeenCalled();
vm.file.key = `unstaged-${vm.file.key}`;
vm.setupEditor(); changeViewMode(FILE_VIEW_MODE_EDITOR);
await vm.$nextTick();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]); expect(updateDimensionsSpy).toHaveBeenCalled();
});
}); });
});
describe('editor updateDimensions', () => { describe('initEditor', () => {
beforeEach(() => { const hideEditorAndRunFn = async () => {
jest.spyOn(vm.editor, 'updateDimensions'); jest.clearAllMocks();
jest.spyOn(vm.editor, 'updateDiffView').mockImplementation(); jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
});
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(() => { vm.initEditor();
expect(vm.editor.updateDimensions).not.toHaveBeenCalled(); await vm.$nextTick();
expect(vm.editor.updateDiffView).not.toHaveBeenCalled(); };
done(); it('does not fetch file information for temp entries', async () => {
}); await createComponent({
activeFile: createActiveFile(),
}); });
it('calls updateDimensions when rightPane is opened', (done) => { expect(vm.getFileData).not.toHaveBeenCalled();
vm.$store.state.rightPane.isOpen = true;
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
expect(vm.editor.updateDiffView).toHaveBeenCalled();
done();
});
});
}); });
describe('show tabs', () => { it('is being initialised for files without content even if shouldHideEditor is `true`', async () => {
it('shows tabs in edit mode', () => { await createComponent({
expect(vm.$el.querySelector('.nav-links')).not.toBe(null); activeFile: dummyFile.empty,
}); });
it('hides tabs in review mode', (done) => { await hideEditorAndRunFn();
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
vm.$nextTick(() => { expect(vm.getFileData).toHaveBeenCalled();
expect(vm.$el.querySelector('.nav-links')).toBe(null); expect(vm.getRawFileData).toHaveBeenCalled();
});
done(); it('does not initialize editor for files already with content when shouldHideEditor is `true`', async () => {
}); await createComponent({
activeFile: createActiveFile(),
}); });
it('hides tabs in commit mode', (done) => { await hideEditorAndRunFn();
vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
vm.$nextTick(() => { expect(vm.getFileData).not.toHaveBeenCalled();
expect(vm.$el.querySelector('.nav-links')).toBe(null); expect(vm.getRawFileData).not.toHaveBeenCalled();
expect(createInstanceSpy).not.toHaveBeenCalled();
});
});
done(); describe('updates on file changes', () => {
}); beforeEach(async () => {
await createComponent({
activeFile: createActiveFile({
content: 'foo', // need to prevent full cycle of initEditor
}),
}); });
jest.spyOn(vm, 'initEditor').mockImplementation();
}); });
describe('when files view mode is preview', () => { it('calls removePendingTab when old file is pending', async () => {
beforeEach((done) => { jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); jest.spyOn(vm, 'removePendingTab').mockImplementation();
changeViewMode(FILE_VIEW_MODE_PREVIEW);
vm.file.name = 'myfile.md';
vm.file.content = 'hello world';
vm.$nextTick(done); const origFile = vm.file;
}); vm.file.pending = true;
await vm.$nextTick();
it('should hide editor', () => { wrapper.setProps({
expect(vm.showEditor).toBe(false); file: file('testing'),
expect(findEditor()).toHaveCss({ display: 'none' });
}); });
vm.file.content = 'foo'; // need to prevent full cycle of initEditor
await vm.$nextTick();
describe('when file view mode changes to editor', () => { expect(vm.removePendingTab).toHaveBeenCalledWith(origFile);
it('should update dimensions', () => {
changeViewMode(FILE_VIEW_MODE_EDITOR);
return vm.$nextTick().then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
});
});
});
}); });
describe('initEditor', () => { it('does not call initEditor if the file did not change', async () => {
beforeEach(() => { Vue.set(vm, 'file', vm.file);
vm.file.tempFile = false; await vm.$nextTick();
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
});
it('does not fetch file information for temp entries', (done) => { expect(vm.initEditor).not.toHaveBeenCalled();
vm.file.tempFile = true; });
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.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(); it('calls initEditor when file key is changed', async () => {
expect(vm.initEditor).not.toHaveBeenCalled();
vm.$nextTick() wrapper.setProps({
.then(() => { file: {
expect(vm.getFileData).toHaveBeenCalled(); ...vm.file,
expect(vm.getRawFileData).toHaveBeenCalled(); key: 'new',
}) },
.then(done)
.catch(done.fail);
}); });
await vm.$nextTick();
it('does not initialize editor for files already with content', (done) => { expect(vm.initEditor).toHaveBeenCalled();
vm.file.content = 'foo'; });
});
vm.initEditor();
vm.$nextTick() describe('populates editor with the fetched content', () => {
.then(() => { const createRemoteFile = (name) => ({
expect(vm.getFileData).not.toHaveBeenCalled(); ...file(name),
expect(vm.getRawFileData).not.toHaveBeenCalled(); tmpFile: false,
expect(vm.editor.createInstance).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
}); });
describe('updates on file changes', () => { beforeEach(async () => {
beforeEach(() => { await createComponent();
jest.spyOn(vm, 'initEditor').mockImplementation(); vm.getRawFileData.mockRestore();
}); });
it('calls removePendingTab when old file is pending', (done) => { it('after switching viewer from edit to diff', async () => {
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true); const f = createRemoteFile('newFile');
jest.spyOn(vm, 'removePendingTab').mockImplementation(); Vue.set(vm.$store.state.entries, f.path, f);
vm.file.pending = true; jest.spyOn(service, 'getRawFileData').mockImplementation(async () => {
expect(vm.file.loading).toBe(true);
vm.$nextTick() // switching from edit to diff mode usually triggers editor initialization
.then(() => { vm.$store.state.viewer = viewerTypes.diff;
vm.file = file('testing');
vm.file.content = 'foo'; // need to prevent full cycle of initEditor
return vm.$nextTick(); // we delay returning the file to make sure editor doesn't initialize before we fetch file content
}) await waitUsingRealTimer(30);
.then(() => { return 'rawFileData123\n';
expect(vm.removePendingTab).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
}); });
it('does not call initEditor if the file did not change', (done) => { wrapper.setProps({
Vue.set(vm, 'file', vm.file); file: f,
vm.$nextTick()
.then(() => {
expect(vm.initEditor).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
}); });
it('calls initEditor when file key is changed', (done) => { await waitForEditorSetup();
expect(vm.initEditor).not.toHaveBeenCalled(); expect(vm.model.getModel().getValue()).toBe('rawFileData123\n');
});
Vue.set(vm, 'file', { it('after opening multiple files at the same time', async () => {
...vm.file, const fileA = createRemoteFile('fileA');
key: 'new', const aContent = 'fileA-rawContent\n';
const bContent = 'fileB-rawContent\n';
const fileB = createRemoteFile('fileB');
Vue.set(vm.$store.state.entries, fileA.path, fileA);
Vue.set(vm.$store.state.entries, fileB.path, fileB);
jest
.spyOn(service, 'getRawFileData')
.mockImplementation(async () => {
// opening fileB while the content of fileA is still being fetched
wrapper.setProps({
file: fileB,
});
return aContent;
})
.mockImplementationOnce(async () => {
// we delay returning fileB content to make sure the editor doesn't initialize prematurely
await waitUsingRealTimer(30);
return bContent;
}); });
vm.$nextTick() wrapper.setProps({
.then(() => { file: fileA,
expect(vm.initEditor).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
}); });
});
describe('populates editor with the fetched content', () => { await waitForEditorSetup();
beforeEach(() => { expect(vm.model.getModel().getValue()).toBe(bContent);
vm.getRawFileData.mockRestore(); });
}); });
const createRemoteFile = (name) => ({ describe('onPaste', () => {
...file(name), const setFileName = (name) =>
tmpFile: false, createActiveFile({
content: 'hello world\n',
name,
path: `foo/${name}`,
key: 'new',
}); });
it('after switching viewer from edit to diff', async () => { const pasteImage = () => {
jest.spyOn(service, 'getRawFileData').mockImplementation(async () => { window.dispatchEvent(
expect(vm.file.loading).toBe(true); Object.assign(new Event('paste'), {
clipboardData: {
// switching from edit to diff mode usually triggers editor initialization files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
store.state.viewer = viewerTypes.diff; },
}),
);
};
// we delay returning the file to make sure editor doesn't initialize before we fetch file content const watchState = (watched) =>
await waitUsingRealTimer(30); new Promise((resolve) => {
return 'rawFileData123\n'; const unwatch = vm.$store.watch(watched, () => {
unwatch();
resolve();
}); });
const f = createRemoteFile('newFile');
Vue.set(store.state.entries, f.path, f);
vm.file = f;
await waitForEditorSetup();
expect(vm.model.getModel().getValue()).toBe('rawFileData123\n');
}); });
it('after opening multiple files at the same time', async () => { // Pasting an image does a lot of things like using the FileReader API,
const fileA = createRemoteFile('fileA'); // so, waitForPromises isn't very reliable (and causes a flaky spec)
const fileB = createRemoteFile('fileB'); // Read more about state.watch: https://vuex.vuejs.org/api/#watch
Vue.set(store.state.entries, fileA.path, fileA); const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content);
Vue.set(store.state.entries, fileB.path, fileB);
jest
.spyOn(service, 'getRawFileData')
.mockImplementationOnce(async () => {
// opening fileB while the content of fileA is still being fetched
vm.file = fileB;
return 'fileA-rawContent\n';
})
.mockImplementationOnce(async () => {
// we delay returning fileB content to make sure the editor doesn't initialize prematurely
await waitUsingRealTimer(30);
return 'fileB-rawContent\n';
});
vm.file = fileA; beforeEach(async () => {
await createComponent({
await waitForEditorSetup(); state: {
expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n'); trees: {
'gitlab-org/gitlab': { tree: [] },
},
currentProjectId: 'gitlab-org',
currentBranchId: 'gitlab',
},
activeFile: setFileName('bar.md'),
}); });
});
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; vm.setupEditor();
};
const pasteImage = () => { await waitForPromises();
window.dispatchEvent( // set cursor to line 2, column 1
Object.assign(new Event('paste'), { vm.editor.setSelection(new Range(2, 1, 2, 1));
clipboardData: { vm.editor.focus();
files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
},
}),
);
};
const watchState = (watched) =>
new Promise((resolve) => {
const unwatch = vm.$store.watch(watched, () => {
unwatch();
resolve();
});
});
// Pasting an image does a lot of things like using the FileReader API, jest.spyOn(vm.editor, 'hasTextFocus').mockReturnValue(true);
// so, waitForPromises isn't very reliable (and causes a flaky spec) });
// Read more about state.watch: https://vuex.vuejs.org/api/#watch
const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content);
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(() => { it('adds an image entry to the same folder for a pasted image in a markdown file', async () => {
// set cursor to line 2, column 1 pasteImage();
vm.editor.instance.setSelection(new Range(2, 1, 2, 1));
vm.editor.instance.focus();
jest.spyOn(vm.editor.instance, 'hasTextFocus').mockReturnValue(true); await waitForFileContentChange();
}); expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
rawPath: 'data:image/png;base64,Zm9v',
}); });
});
it('adds an image entry to the same folder for a pasted image in a markdown file', () => { it("adds a markdown image tag to the file's contents", async () => {
pasteImage(); pasteImage();
return waitForFileContentChange().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
rawPath: 'data:image/png;base64,Zm9v',
});
});
});
it("adds a markdown image tag to the file's contents", () => { await waitForFileContentChange();
pasteImage(); expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
});
return waitForFileContentChange().then(() => { it("does not add file to state or set markdown image syntax if the file isn't markdown", async () => {
expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)'); wrapper.setProps({
}); file: setFileName('myfile.txt'),
}); });
pasteImage();
it("does not add file to state or set markdown image syntax if the file isn't markdown", () => { await waitForPromises();
setFileName('myfile.txt'); expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
pasteImage(); expect(vm.file.content).toBe('hello world\n');
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
expect(vm.file.content).toBe('hello world\n');
});
});
}); });
}); });
describe('fetchEditorconfigRules', () => { describe('fetchEditorconfigRules', () => {
beforeEach(() => {
exampleConfigs.forEach(({ path, content }) => {
store.state.entries[path] = { ...file(), path, content };
});
});
it.each(exampleFiles)( it.each(exampleFiles)(
'does not fetch content from remote for .editorconfig files present locally (case %#)', 'does not fetch content from remote for .editorconfig files present locally (case %#)',
({ path, monacoRules }) => { async ({ path, monacoRules }) => {
createOpenFile(path); await createComponent({
createComponent(); state: {
entries: (() => {
return waitForEditorSetup().then(() => { const res = {};
expect(vm.rules).toEqual(monacoRules); exampleConfigs.forEach(({ path: configPath, content }) => {
expect(vm.model.options).toMatchObject(monacoRules); res[configPath] = { ...file(), path: configPath, content };
expect(vm.getFileData).not.toHaveBeenCalled(); });
expect(vm.getRawFileData).not.toHaveBeenCalled(); return res;
})(),
},
activeFile: createActiveFile({
path,
key: path,
name: 'myfile.txt',
content: 'hello world',
}),
}); });
expect(vm.rules).toEqual(monacoRules);
expect(vm.model.options).toMatchObject(monacoRules);
expect(vm.getFileData).not.toHaveBeenCalled();
expect(vm.getRawFileData).not.toHaveBeenCalled();
}, },
); );
it('fetches content from remote for .editorconfig files not available locally', () => { it('fetches content from remote for .editorconfig files not available locally', async () => {
exampleConfigs.forEach(({ path }) => { const activeFile = createActiveFile({
delete store.state.entries[path].content; path: 'foo/bar/baz/test/my_spec.js',
delete store.state.entries[path].raw; key: 'foo/bar/baz/test/my_spec.js',
name: 'myfile.txt',
content: 'hello world',
});
const expectations = [
'foo/bar/baz/.editorconfig',
'foo/bar/.editorconfig',
'foo/.editorconfig',
'.editorconfig',
];
await createComponent({
state: {
entries: (() => {
const res = {
[activeFile.path]: activeFile,
};
exampleConfigs.forEach(({ path: configPath }) => {
const f = { ...file(), path: configPath };
delete f.content;
delete f.raw;
res[configPath] = f;
});
return res;
})(),
},
activeFile,
}); });
// Include a "test" directory which does not exist in store. This one should be skipped. expect(service.getFileData.mock.calls.map(([args]) => args)).toEqual(
createOpenFile('foo/bar/baz/test/my_spec.js'); expectations.map((expectation) => expect.stringContaining(expectation)),
createComponent(); );
expect(service.getRawFileData.mock.calls.map(([args]) => args)).toEqual(
return waitForEditorSetup().then(() => { expectations.map((expectation) => expect.objectContaining({ path: expectation })),
expect(vm.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(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([
{ path: 'foo/bar/baz/.editorconfig' },
{ path: 'foo/bar/.editorconfig' },
{ path: 'foo/.editorconfig' },
{ path: '.editorconfig' },
]);
});
}); });
}); });
}); });
...@@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar'); ...@@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar');
export const waitForMonacoEditor = () => export const waitForMonacoEditor = () =>
new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve)); new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve));
export const waitForEditorModelChange = (instance) =>
new Promise((resolve) => instance.onDidChangeModel(resolve));
export const findMonacoEditor = () => export const findMonacoEditor = () =>
screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor')); screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor'));
......
/* global monaco */
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { initIde } from '~/ide'; import { initIde } from '~/ide';
import Editor from '~/ide/lib/editor';
import extendStore from '~/ide/stores/extend'; import extendStore from '~/ide/stores/extend';
import { IDE_DATASET } from './mock_data'; import { IDE_DATASET } from './mock_data';
...@@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) = ...@@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) =
const vm = initIde(el, { extendStore }); const vm = initIde(el, { extendStore });
// We need to dispose of editor Singleton things or tests will bump into eachother // We need to dispose of editor Singleton things or tests will bump into eachother
vm.$on('destroy', () => { vm.$on('destroy', () => monaco.editor.getModels().forEach((model) => model.dispose()));
if (Editor.editorInstance) {
Editor.editorInstance.modelManager.dispose();
Editor.editorInstance.dispose();
Editor.editorInstance = null;
}
});
return vm; return vm;
}; };
...@@ -96,16 +96,6 @@ describe('WebIDE', () => { ...@@ -96,16 +96,6 @@ describe('WebIDE', () => {
let statusBar; let statusBar;
let editor; let editor;
const waitForEditor = async () => {
editor = await ideHelper.waitForMonacoEditor();
};
const changeEditorPosition = async (lineNumber, column) => {
editor.setPosition({ lineNumber, column });
await vm.$nextTick();
};
beforeEach(async () => { beforeEach(async () => {
vm = startWebIDE(container); vm = startWebIDE(container);
...@@ -134,16 +124,17 @@ describe('WebIDE', () => { ...@@ -134,16 +124,17 @@ describe('WebIDE', () => {
// Need to wait for monaco editor to load so it doesn't through errors on dispose // Need to wait for monaco editor to load so it doesn't through errors on dispose
await ideHelper.openFile('.gitignore'); await ideHelper.openFile('.gitignore');
await ideHelper.waitForMonacoEditor(); await ideHelper.waitForEditorModelChange(editor);
await ideHelper.openFile('README.md'); await ideHelper.openFile('README.md');
await ideHelper.waitForMonacoEditor(); await ideHelper.waitForEditorModelChange(editor);
expect(el).toHaveText(markdownPreview); expect(el).toHaveText(markdownPreview);
}); });
describe('when editor position changes', () => { describe('when editor position changes', () => {
beforeEach(async () => { beforeEach(async () => {
await changeEditorPosition(4, 10); editor.setPosition({ lineNumber: 4, column: 10 });
await vm.$nextTick();
}); });
it('shows new line position', () => { it('shows new line position', () => {
...@@ -153,7 +144,8 @@ describe('WebIDE', () => { ...@@ -153,7 +144,8 @@ describe('WebIDE', () => {
it('updates after rename', async () => { it('updates after rename', async () => {
await ideHelper.renameFile('README.md', 'READMEZ.txt'); await ideHelper.renameFile('README.md', 'READMEZ.txt');
await waitForEditor(); await ideHelper.waitForEditorModelChange(editor);
await vm.$nextTick();
expect(statusBar).toHaveText('1:1'); expect(statusBar).toHaveText('1:1');
expect(statusBar).toHaveText('plaintext'); expect(statusBar).toHaveText('plaintext');
...@@ -161,10 +153,10 @@ describe('WebIDE', () => { ...@@ -161,10 +153,10 @@ describe('WebIDE', () => {
it('persists position after opening then rename', async () => { it('persists position after opening then rename', async () => {
await ideHelper.openFile('files/js/application.js'); await ideHelper.openFile('files/js/application.js');
await waitForEditor(); await ideHelper.waitForEditorModelChange(editor);
await ideHelper.renameFile('README.md', 'READING_RAINBOW.md'); await ideHelper.renameFile('README.md', 'READING_RAINBOW.md');
await ideHelper.openFile('READING_RAINBOW.md'); await ideHelper.openFile('READING_RAINBOW.md');
await waitForEditor(); await ideHelper.waitForEditorModelChange(editor);
expect(statusBar).toHaveText('4:10'); expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown'); expect(statusBar).toHaveText('markdown');
...@@ -173,7 +165,8 @@ describe('WebIDE', () => { ...@@ -173,7 +165,8 @@ describe('WebIDE', () => {
it('persists position after closing', async () => { it('persists position after closing', async () => {
await ideHelper.closeFile('README.md'); await ideHelper.closeFile('README.md');
await ideHelper.openFile('README.md'); await ideHelper.openFile('README.md');
await waitForEditor(); await ideHelper.waitForMonacoEditor();
await vm.$nextTick();
expect(statusBar).toHaveText('4:10'); expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown'); expect(statusBar).toHaveText('markdown');
......
...@@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => { ...@@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => {
vm = startWebIDE(container, { mrId }); vm = startWebIDE(container, { mrId });
await ideHelper.waitForTabToOpen(basename(changes[0].new_path)); const editor = await ideHelper.waitForMonacoEditor();
await ideHelper.waitForMonacoEditor(); await ideHelper.waitForEditorModelChange(editor);
}); });
afterEach(async () => { afterEach(() => {
vm.$destroy(); vm.$destroy();
vm = null; vm = null;
}); });
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment