Commit 865ad4aa authored by Paul Slaughter's avatar Paul Slaughter Committed by Phil Hughes

Create IDE terminal sync store module

**Notes:**
This interfaces with the `mirror` service and maintains
a state for it's current status.
parent 9314bede
......@@ -6,3 +6,5 @@ class IdeController < ApplicationController
def index
end
end
IdeController.prepend(EE::IdeController)
import terminal from './plugins/terminal';
import terminalSync from './plugins/terminal_sync';
const plugins = [terminal];
const plugins = () => [
terminal,
...(gon.features && gon.features.buildServiceProxy ? [terminalSync] : []),
];
export default (store, el) => {
// plugins is actually an array of plugin factories, so we have to create first then call
plugins.forEach(plugin => plugin(el)(store));
plugins().forEach(plugin => plugin(el)(store));
return store;
};
import * as types from './mutation_types';
import mirror, { canConnect } from '../../../lib/mirror';
export const upload = ({ rootState, commit }) => {
commit(types.START_LOADING);
return mirror
.upload(rootState)
.then(() => {
commit(types.SET_SUCCESS);
})
.catch(err => {
commit(types.SET_ERROR, err);
});
};
export const stop = ({ commit }) => {
mirror.disconnect();
commit(types.STOP);
};
export const start = ({ rootState, commit }) => {
const { session } = rootState.terminal;
const path = session && session.proxyWebsocketPath;
if (!path || !canConnect(session)) {
return Promise.reject();
}
commit(types.START_LOADING);
return mirror
.connect(path)
.then(() => {
commit(types.SET_SUCCESS);
})
.catch(err => {
commit(types.SET_ERROR, err);
return Promise.reject(err);
});
};
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
export default () => ({
namespaced: true,
actions,
mutations,
state: state(),
});
export const START_LOADING = 'START_LOADING';
export const SET_ERROR = 'SET_ERROR';
export const SET_SUCCESS = 'SET_SUCCESS';
export const STOP = 'STOP';
import * as types from './mutation_types';
export default {
[types.START_LOADING](state) {
state.isLoading = true;
state.isError = false;
},
[types.SET_ERROR](state, { message }) {
state.isLoading = false;
state.isError = true;
state.message = message;
},
[types.SET_SUCCESS](state) {
state.isLoading = false;
state.isError = false;
state.isStarted = true;
},
[types.STOP](state) {
state.isLoading = false;
state.isStarted = false;
},
};
export default () => ({
isLoading: false,
isStarted: false,
isError: false,
message: '',
});
import eventHub from '~/ide/eventhub';
import mirror from '../../lib/mirror';
export default function createMirrorPlugin() {
return store => {
eventHub.$on('editor.save', () => mirror.upload(store.state));
};
}
import _ from 'underscore';
import eventHub from '~/ide/eventhub';
import terminalSyncModule from '../modules/terminal_sync';
import { isEndingStatus, isRunningStatus } from '../../utils';
const UPLOAD_DEBOUNCE = 200;
/**
* Registers and controls the terminalSync vuex module based on IDE events.
*
* - Watches the terminal session status state to control start/stop.
* - Listens for file change event to control upload.
*/
export default function createMirrorPlugin() {
return store => {
store.registerModule('terminalSync', terminalSyncModule());
const upload = _.debounce(() => {
store.dispatch(`terminalSync/upload`);
}, UPLOAD_DEBOUNCE);
const stop = () => {
store.dispatch(`terminalSync/stop`);
eventHub.$off('ide.files.change', upload);
};
const start = () => {
store
.dispatch(`terminalSync/start`)
.then(() => {
eventHub.$on('ide.files.change', upload);
})
.catch(() => {
// error is handled in store
});
};
store.watch(
x => x.terminal && x.terminal.session && x.terminal.session.status,
val => {
if (isRunningStatus(val)) {
start();
} else if (isEndingStatus(val)) {
stop();
}
},
);
};
}
# frozen_string_literal: true
module EE
module IdeController
extend ActiveSupport::Concern
prepended do
before_action do
push_frontend_feature_flag(:build_service_proxy)
end
end
end
end
import extendStore from 'ee/ide/stores/extend';
import terminalPlugin from 'ee/ide/stores/plugins/terminal';
import terminalSyncPlugin from 'ee/ide/stores/plugins/terminal_sync';
jest.mock('ee/ide/stores/plugins/terminal', () => {
const plugin = jest.fn();
return jest.fn(() => plugin);
});
jest.mock('ee/ide/stores/plugins/terminal', () => jest.fn());
jest.mock('ee/ide/stores/plugins/terminal_sync', () => jest.fn());
describe('ee/ide/stores/extend', () => {
let prevGon;
let store;
let el;
beforeEach(() => {
prevGon = global.gon;
store = {};
el = {};
extendStore(store, el);
[terminalPlugin, terminalSyncPlugin].forEach(x => {
const plugin = jest.fn();
x.mockImplementation(() => plugin);
});
});
afterEach(() => {
global.gon = prevGon;
terminalPlugin.mockClear();
terminalSyncPlugin.mockClear();
});
it('creates terminal plugin', () => {
expect(terminalPlugin).toHaveBeenCalledWith(el);
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);
});
});
it('calls terminal plugin', () => {
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();
});
});
});
});
import * as actions from 'ee/ide/stores/modules/terminal_sync/actions';
import mirror, { canConnect, SERVICE_NAME } from 'ee/ide/lib/mirror';
import * as types from 'ee/ide/stores/modules/terminal_sync/mutation_types';
import testAction from 'helpers/vuex_action_helper';
jest.mock('ee/ide/lib/mirror');
const TEST_SESSION = {
proxyWebsocketPath: 'test/path',
services: [SERVICE_NAME],
};
describe('ee/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 } } });
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 });
expect(result).rejects.toEqual(err);
return result.catch(() => {
expect(commit).toHaveBeenCalledWith(types.SET_ERROR, err);
});
});
});
});
});
import createState from 'ee/ide/stores/modules/terminal_sync/state';
import * as types from 'ee/ide/stores/modules/terminal_sync/mutation_types';
import mutations from 'ee/ide/stores/modules/terminal_sync/mutations';
const TEST_MESSAGE = 'lorem ipsum dolar sit';
describe('ee/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,
}),
);
});
});
});
import eventHub from '~/ide/eventhub';
import { createStore } from '~/ide/stores';
import createMirrorPlugin from 'ee/ide/stores/plugins/mirror';
import mirror from 'ee/ide/lib/mirror';
jest.mock('ee/ide/lib/mirror');
describe('EE IDE stores/plugins/mirror', () => {
let store;
let plugin;
beforeEach(() => {
store = createStore();
plugin = createMirrorPlugin();
plugin(store);
});
it('does not initally call upload', () => {
expect(mirror.upload).not.toHaveBeenCalled();
});
it('uploads on editor.save event', () => {
eventHub.$emit('editor.save');
expect(mirror.upload).toHaveBeenCalledWith(store.state);
});
});
import eventHub from '~/ide/eventhub';
import { createStore } from '~/ide/stores';
import createTerminalPlugin from 'ee/ide/stores/plugins/terminal';
import createTerminalSyncPlugin from 'ee/ide/stores/plugins/terminal_sync';
import { SET_SESSION_STATUS } from 'ee/ide/stores/modules/terminal/mutation_types';
import { RUNNING, STOPPING } from 'ee/ide/constants';
jest.mock('ee/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('EE 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);
});
});
});
});
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