Commit b6052c49 authored by Phil Hughes's avatar Phil Hughes

Merge branch '5426-fe-web-terminal-controls' into 'master'

Web Terminal FE

See merge request gitlab-org/gitlab-ee!8732
parents c5e6362b dab9d76d
......@@ -7,3 +7,8 @@ export const addClassIfElementExists = (element, className) => {
};
export const isInVueNoteablePage = () => isInIssuePage() || isInEpicPage() || isInMRPage();
export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin;
export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) =>
scrollTop + offsetHeight < scrollHeight - margin;
import Terminal from './terminal';
export default () => new Terminal({ selector: '#terminal' });
export default () => new Terminal(document.getElementById('terminal'));
import _ from 'underscore';
import $ from 'jquery';
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils';
const SCROLL_MARGIN = 5;
Terminal.applyAddon(fit);
export default class GLTerminal {
constructor(options = {}) {
constructor(element, options = {}) {
this.options = Object.assign(
{},
{
......@@ -13,7 +19,8 @@ export default class GLTerminal {
options,
);
this.container = document.querySelector(options.selector);
this.container = element;
this.onDispose = [];
this.setSocketUrl();
this.createTerminal();
......@@ -34,8 +41,6 @@ export default class GLTerminal {
}
createTerminal() {
Terminal.applyAddon(fit);
this.terminal = new Terminal(this.options);
this.socket = new WebSocket(this.socketUrl, ['terminal.gitlab.com']);
......@@ -72,4 +77,48 @@ export default class GLTerminal {
handleSocketFailure() {
this.terminal.write('\r\nConnection failure');
}
addScrollListener(onScrollLimit) {
const viewport = this.container.querySelector('.xterm-viewport');
const listener = _.throttle(() => {
onScrollLimit({
canScrollUp: canScrollUp(viewport, SCROLL_MARGIN),
canScrollDown: canScrollDown(viewport, SCROLL_MARGIN),
});
});
this.onDispose.push(() => viewport.removeEventListener('scroll', listener));
viewport.addEventListener('scroll', listener);
// don't forget to initialize value before scroll!
listener({ target: viewport });
}
disable() {
this.terminal.setOption('cursorBlink', false);
this.terminal.setOption('theme', { foreground: '#707070' });
this.terminal.setOption('disableStdin', true);
this.socket.close();
}
dispose() {
this.terminal.off('data');
this.terminal.dispose();
this.socket.close();
this.onDispose.forEach(fn => fn());
this.onDispose.length = 0;
}
scrollToTop() {
this.terminal.scrollToTop();
}
scrollToBottom() {
this.terminal.scrollToBottom();
}
fit() {
this.terminal.fit();
}
}
......@@ -390,3 +390,4 @@ img.emoji {
.flex-no-shrink { flex-shrink: 0; }
.mw-460 { max-width: 460px; }
.ws-initial { white-space: initial; }
.min-height-0 { min-height: 0; }
@mixin ide-trace-view {
display: flex;
flex-direction: column;
height: 100%;
margin-top: -$grid-size;
margin-bottom: -$grid-size;
&.build-page .top-bar {
top: 0;
height: auto;
font-size: 12px;
border-top-right-radius: $border-radius-default;
}
.top-bar {
margin-left: -$gl-padding;
}
}
@import 'framework/variables';
@import 'framework/mixins';
@import './ide_mixins';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
......@@ -1111,11 +1112,7 @@ $ide-commit-header-height: 48px;
}
.ide-pipeline {
display: flex;
flex-direction: column;
height: 100%;
margin-top: -$grid-size;
margin-bottom: -$grid-size;
@include ide-trace-view();
.empty-state {
margin-top: auto;
......@@ -1133,17 +1130,9 @@ $ide-commit-header-height: 48px;
}
}
.build-trace,
.top-bar {
.build-trace {
margin-left: -$gl-padding;
}
&.build-page .top-bar {
top: 0;
height: auto;
font-size: 12px;
border-top-right-radius: $border-radius-default;
}
}
.ide-pipeline-list {
......
<script>
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import Terminal from './terminal.vue';
import { isEndingStatus } from '../../utils';
export default {
components: {
Terminal,
},
computed: {
...mapState('terminal', ['session']),
actionButton() {
if (isEndingStatus(this.session.status)) {
return {
action: () => this.restartSession(),
text: __('Restart Terminal'),
class: 'btn-primary',
};
}
return {
action: () => this.stopSession(),
text: __('Stop Terminal'),
class: 'btn-inverted btn-remove',
};
},
},
methods: {
...mapActions('terminal', ['restartSession', 'stopSession']),
},
};
</script>
<template>
<div v-if="session" class="ide-terminal build-page d-flex flex-column">
<header class="ide-job-header d-flex align-items-center">
<h5>{{ __('Web Terminal') }}</h5>
<div class="ml-auto align-self-center">
<button
v-if="actionButton"
type="button"
class="btn btn-sm"
:class="actionButton.class"
@click="actionButton.action"
>
{{ actionButton.text }}
</button>
</div>
</header>
<terminal :terminal-path="session.terminalPath" :status="session.status" />
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import GLTerminal from '~/terminal/terminal';
import TerminalControls from './terminal_controls.vue';
import { RUNNING, STOPPING } from '../../constants';
import { isStartingStatus } from '../../utils';
export default {
components: {
GlLoadingIcon,
TerminalControls,
},
props: {
terminalPath: {
type: String,
required: false,
default: '',
},
status: {
type: String,
required: true,
},
},
data() {
return {
glterminal: null,
canScrollUp: false,
canScrollDown: false,
};
},
computed: {
...mapState(['panelResizing']),
loadingText() {
if (isStartingStatus(this.status)) {
return __('Starting...');
} else if (this.status === STOPPING) {
return __('Stopping...');
}
return '';
},
},
watch: {
panelResizing() {
if (!this.panelResizing && this.glterminal) {
this.glterminal.fit();
}
},
status() {
this.refresh();
},
terminalPath() {
this.refresh();
},
},
beforeDestroy() {
this.destroyTerminal();
},
methods: {
refresh() {
if (this.status === RUNNING && this.terminalPath) {
this.createTerminal();
} else if (this.status === STOPPING) {
this.stopTerminal();
}
},
createTerminal() {
this.destroyTerminal();
this.glterminal = new GLTerminal(this.$refs.terminal);
this.glterminal.addScrollListener(({ canScrollUp, canScrollDown }) => {
this.canScrollUp = canScrollUp;
this.canScrollDown = canScrollDown;
});
},
destroyTerminal() {
if (this.glterminal) {
this.glterminal.dispose();
this.glterminal = null;
}
},
stopTerminal() {
if (this.glterminal) {
this.glterminal.disable();
}
},
},
};
</script>
<template>
<div class="d-flex flex-column flex-fill min-height-0">
<div class="top-bar d-flex border-left-0 align-items-center">
<div v-if="loadingText">
<gl-loading-icon :inline="true" />
<span>{{ loadingText }}</span>
</div>
<terminal-controls
v-if="glterminal"
class="ml-auto"
:can-scroll-up="canScrollUp"
:can-scroll-down="canScrollDown"
@scroll-up="glterminal.scrollToTop();"
@scroll-down="glterminal.scrollToBottom();"
/>
</div>
<div class="terminal-wrapper d-flex flex-fill min-height-0">
<div
ref="terminal"
class="ide-terminal-trace flex-fill min-height-0 w-100"
:data-project-path="terminalPath"
></div>
</div>
</div>
</template>
<script>
import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
export default {
components: {
ScrollButton,
},
props: {
canScrollUp: {
type: Boolean,
required: false,
default: false,
},
canScrollDown: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div class="controllers">
<scroll-button :disabled="!canScrollUp" direction="up" @click="$emit('scroll-up');" />
<scroll-button :disabled="!canScrollDown" direction="down" @click="$emit('scroll-down');" />
</div>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
import TerminalSession from './session.vue';
export default {
components: {
EmptyState,
TerminalSession,
},
computed: {
...mapState('terminal', ['isShowSplash', 'paths']),
...mapGetters('terminal', ['allCheck']),
},
methods: {
...mapActions('terminal', ['hideSplash']),
...mapActions('terminal', ['startSession', 'hideSplash']),
start() {
this.startSession();
this.hideSplash();
},
},
......@@ -32,7 +35,7 @@ export default {
/>
</div>
<template v-else>
<h5>{{ __('Web Terminal') }}</h5>
<terminal-session />
</template>
</div>
</template>
export const CHECK_CONFIG = 'config';
export const CHECK_RUNNERS = 'runners';
export const RETRY_RUNNERS_INTERVAL = 10000;
export const STARTING = 'starting';
export const PENDING = 'pending';
export const RUNNING = 'running';
export const STOPPING = 'stopping';
export const STOPPED = 'stopped';
import axios from '~/lib/utils/axios_utils';
export const baseUrl = projectPath => `/${projectPath}/ide_terminals`;
export const checkConfig = (projectPath, branch) =>
axios.post(`${baseUrl(projectPath)}/check_config`, {
branch,
format: 'json',
});
export const create = (projectPath, branch) =>
axios.post(baseUrl(projectPath), {
branch,
format: 'json',
});
......@@ -3,10 +3,10 @@ import terminalModule from './modules/terminal';
function getPathsFromData(el) {
return {
ciYamlHelpPath: el.dataset.eeCiYamlHelpPath,
ciRunnersHelpPath: el.dataset.eeCiRunnersHelpPath,
webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath,
webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath,
webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath,
};
}
......
......@@ -3,6 +3,7 @@ import httpStatus from '~/lib/utils/http_status';
import * as types from '../mutation_types';
import * as messages from '../messages';
import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../../../../constants';
import * as terminalService from '../../../../services/terminals';
export const requestConfigCheck = ({ commit }) => {
commit(types.REQUEST_CHECK, CHECK_CONFIG);
......@@ -20,15 +21,18 @@ export const receiveConfigCheckError = ({ commit, state }, e) => {
const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND;
commit(types.SET_VISIBLE, isVisible);
const message = messages.configCheckError(status, paths.ciYamlHelpPath);
const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath);
commit(types.RECEIVE_CHECK_ERROR, { type: CHECK_CONFIG, message });
};
export const fetchConfigCheck = ({ dispatch }) => {
export const fetchConfigCheck = ({ dispatch, rootState, rootGetters }) => {
dispatch('requestConfigCheck');
// This will use a real endpoint in https://gitlab.com/gitlab-org/gitlab-ee/issues/5426
Promise.resolve({})
const { currentBranchId } = rootState;
const { currentProject } = rootGetters;
terminalService
.checkConfig(currentProject.path_with_namespace, currentBranchId)
.then(() => {
dispatch('receiveConfigCheckSuccess');
})
......@@ -49,7 +53,7 @@ export const receiveRunnersCheckSuccess = ({ commit, dispatch, state }, data) =>
commit(types.RECEIVE_CHECK_ERROR, {
type: CHECK_RUNNERS,
message: messages.runnersCheckEmpty(paths.ciRunnersHelpPath),
message: messages.runnersCheckEmpty(paths.webTerminalRunnersHelpPath),
});
dispatch('retryRunnersCheck');
......
export * from './setup';
export * from './checks';
export * from './session_controls';
export * from './session_status';
export default () => {};
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import flash from '~/flash';
import * as types from '../mutation_types';
import * as messages from '../messages';
import * as terminalService from '../../../../services/terminals';
import { STARTING, STOPPING, STOPPED } from '../../../../constants';
export const requestStartSession = ({ commit }) => {
commit(types.SET_SESSION_STATUS, STARTING);
};
export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => {
commit(types.SET_SESSION, {
id: data.id,
status: data.status,
showPath: data.show_path,
cancelPath: data.cancel_path,
retryPath: data.retry_path,
terminalPath: data.terminal_path,
});
dispatch('pollSessionStatus');
};
export const receiveStartSessionError = ({ dispatch }) => {
flash(messages.UNEXPECTED_ERROR_STARTING);
dispatch('killSession');
};
export const startSession = ({ state, dispatch, rootGetters, rootState }) => {
if (state.session && state.session.status === STARTING) {
return;
}
const { currentProject } = rootGetters;
const { currentBranchId } = rootState;
dispatch('requestStartSession');
terminalService
.create(currentProject.path_with_namespace, currentBranchId)
.then(({ data }) => {
dispatch('receiveStartSessionSuccess', data);
})
.catch(error => {
dispatch('receiveStartSessionError', error);
});
};
export const requestStopSession = ({ commit }) => {
commit(types.SET_SESSION_STATUS, STOPPING);
};
export const receiveStopSessionSuccess = ({ dispatch }) => {
dispatch('killSession');
};
export const receiveStopSessionError = ({ dispatch }) => {
flash(messages.UNEXPECTED_ERROR_STOPPING);
dispatch('killSession');
};
export const stopSession = ({ state, dispatch }) => {
const { cancelPath } = state.session;
dispatch('requestStopSession');
axios
.post(cancelPath)
.then(() => {
dispatch('receiveStopSessionSuccess');
})
.catch(err => {
dispatch('receiveStopSessionError', err);
});
};
export const killSession = ({ commit, dispatch }) => {
dispatch('stopPollingSessionStatus');
commit(types.SET_SESSION_STATUS, STOPPED);
};
export const restartSession = ({ state, dispatch, rootState }) => {
const { status, retryPath } = state.session;
const { currentBranchId } = rootState;
if (status !== STOPPED) {
return;
}
if (!retryPath) {
dispatch('startSession');
return;
}
dispatch('requestStartSession');
axios
.post(retryPath, { branch: currentBranchId, format: 'json' })
.then(({ data }) => {
dispatch('receiveStartSessionSuccess', data);
})
.catch(error => {
const responseStatus = error.response && error.response.status;
// We may have removed the build, in this case we'll just create a new session
if (
responseStatus === httpStatus.NOT_FOUND ||
responseStatus === httpStatus.UNPROCESSABLE_ENTITY
) {
dispatch('startSession');
} else {
dispatch('receiveStartSessionError', error);
}
});
};
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import * as types from '../mutation_types';
import * as messages from '../messages';
import { isEndingStatus } from '../../../../utils';
export const pollSessionStatus = ({ state, dispatch, commit }) => {
dispatch('stopPollingSessionStatus');
dispatch('fetchSessionStatus');
const interval = setInterval(() => {
if (!state.session) {
dispatch('stopPollingSessionStatus');
} else {
dispatch('fetchSessionStatus');
}
}, 5000);
commit(types.SET_SESSION_STATUS_INTERVAL, interval);
};
export const stopPollingSessionStatus = ({ state, commit }) => {
const { sessionStatusInterval } = state;
if (!sessionStatusInterval) {
return;
}
clearInterval(sessionStatusInterval);
commit(types.SET_SESSION_STATUS_INTERVAL, 0);
};
export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => {
const status = data && data.status;
commit(types.SET_SESSION_STATUS, status);
if (isEndingStatus(status)) {
dispatch('killSession');
}
};
export const receiveSessionStatusError = ({ dispatch }) => {
flash(messages.UNEXPECTED_ERROR_STATUS);
dispatch('killSession');
};
export const fetchSessionStatus = ({ dispatch, state }) => {
if (!state.session) {
return;
}
const { showPath } = state.session;
axios
.get(showPath)
.then(({ data }) => {
dispatch('receiveSessionStatusSuccess', data);
})
.catch(error => {
dispatch('receiveSessionStatusError', error);
});
};
import * as types from '../mutation_types';
// This will be used in https://gitlab.com/gitlab-org/gitlab-ee/issues/5426
// export const init = ({ dispatch }) => {
// dispatch('fetchConfigCheck');
// dispatch('fetchRunnersCheck');
// };
export const init = () => {};
export const init = ({ dispatch }) => {
dispatch('fetchConfigCheck');
dispatch('fetchRunnersCheck');
};
export const hideSplash = ({ commit }) => {
commit(types.HIDE_SPLASH);
......
......@@ -8,8 +8,17 @@ export const UNEXPECTED_ERROR_CONFIG = __(
export const UNEXPECTED_ERROR_RUNNERS = __(
'An unexpected error occurred while checking the project runners.',
);
export const UNEXPECTED_ERROR_STATUS = __(
'An unexpected error occurred while communicating with the Web Terminal.',
);
export const UNEXPECTED_ERROR_STARTING = __(
'An unexpected error occurred while starting the Web Terminal.',
);
export const UNEXPECTED_ERROR_STOPPING = __(
'An unexpected error occurred while stopping the Web Terminal.',
);
export const EMPTY_RUNNERS = __(
'Configure GitLab runners to start using Web Terminal. %{helpStart}Learn more.%{helpEnd}',
'Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
);
export const ERROR_CONFIG = __(
'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
......
......@@ -5,3 +5,7 @@ export const SET_PATHS = 'SET_PATHS';
export const REQUEST_CHECK = 'REQUEST_CHECK';
export const RECEIVE_CHECK_SUCCESS = 'RECEIVE_CHECK_SUCCESS';
export const RECEIVE_CHECK_ERROR = 'RECEIVE_CHECK_ERROR';
export const SET_SESSION = 'SET_SESSION';
export const SET_SESSION_STATUS = 'SET_SESSION_STATUS';
export const SET_SESSION_STATUS_INTERVAL = 'SET_SESSION_STATUS_INTERVAL';
......@@ -41,4 +41,24 @@ export default {
},
});
},
[types.SET_SESSION](state, session) {
Object.assign(state, {
session,
});
},
[types.SET_SESSION_STATUS](state, status) {
const session = {
...(state.session || {}),
status,
};
Object.assign(state, {
session,
});
},
[types.SET_SESSION_STATUS_INTERVAL](state, sessionStatusInterval) {
Object.assign(state, {
sessionStatusInterval,
});
},
};
......@@ -8,4 +8,6 @@ export default () => ({
isVisible: false,
isShowSplash: true,
paths: {},
session: null,
sessionStatusInterval: 0,
});
import { STARTING, PENDING, RUNNING } from './constants';
export const isStartingStatus = status => status === STARTING || status === PENDING;
export const isRunningStatus = status => status === RUNNING;
export const isEndingStatus = status => !isStartingStatus(status) && !isRunningStatus(status);
@import 'page_bundles/ide_mixins';
.ide-terminal {
@include ide-trace-view();
.terminal-wrapper {
margin-left: -$gl-padding;
background: $black;
color: $gray-darkest;
overflow: hidden;
}
.xterm {
height: 100%;
padding: $grid-size;
}
.xterm-viewport {
overflow-y: auto;
}
}
......@@ -8,9 +8,9 @@ module EE
def ide_data
super.merge({
"ee-web-terminal-svg-path" => image_path('illustrations/web-ide_promotion.svg'),
"ee-ci-yaml-help-path" => help_page_path('ci/yaml/README.md'),
"ee-ci-runners-help-path" => help_page_path('ci/runners/README.md'),
"ee-web-terminal-help-path" => help_page_path('user/project/web_ide/index.md', anchor: 'client-side-evaluation')
"ee-web-terminal-help-path" => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-terminal-ultimate'),
"ee-web-terminal-config-help-path" => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'),
"ee-web-terminal-runners-help-path" => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration-ultimate-only')
})
end
end
......
- content_for :page_specific_javascripts do
= stylesheet_link_tag "xterm.css"
= render partial: "ide/show"
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import TerminalSession from 'ee/ide/components/terminal/session.vue';
import Terminal from 'ee/ide/components/terminal/terminal.vue';
import { STARTING, PENDING, RUNNING, STOPPING, STOPPED } from 'ee/ide/constants';
const TEST_TERMINAL_PATH = 'terminal/path';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE IDE TerminalSession', () => {
let wrapper;
let actions;
let state;
const factory = (options = {}) => {
const store = new Vuex.Store({
modules: {
terminal: {
namespaced: true,
actions,
state,
},
},
});
wrapper = shallowMount(localVue.extend(TerminalSession), {
localVue,
store,
...options,
});
};
beforeEach(() => {
state = {
session: { status: RUNNING, terminalPath: TEST_TERMINAL_PATH },
};
actions = {
restartSession: jasmine.createSpy('restartSession'),
stopSession: jasmine.createSpy('stopSession'),
};
});
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');
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');
expect(button.text()).toEqual('Restart Terminal');
expect(actions.restartSession).toHaveBeenCalled();
});
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import TerminalControls from 'ee/ide/components/terminal/terminal_controls.vue';
import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
const localVue = createLocalVue();
describe('EE IDE TerminalControls', () => {
let wrapper;
let buttons;
const factory = (options = {}) => {
wrapper = shallowMount(localVue.extend(TerminalControls), {
localVue,
...options,
});
buttons = wrapper.findAll(ScrollButton);
};
it('shows an up and down scroll button', () => {
factory();
expect(buttons.wrappers.map(x => x.props())).toEqual([
jasmine.objectContaining({ direction: 'up', disabled: true }),
jasmine.objectContaining({ direction: 'down', disabled: true }),
]);
});
it('enables up button with prop', () => {
factory({ propsData: { canScrollUp: true } });
expect(buttons.at(0).props()).toEqual(
jasmine.objectContaining({ direction: 'up', disabled: false }),
);
});
it('enables down button with prop', () => {
factory({ propsData: { canScrollDown: true } });
expect(buttons.at(1).props()).toEqual(
jasmine.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');
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');
expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-down', args: [] }]);
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import Terminal from 'ee/ide/components/terminal/terminal.vue';
import TerminalControls from 'ee/ide/components/terminal/terminal_controls.vue';
import { STARTING, PENDING, RUNNING, STOPPING, STOPPED } from 'ee/ide/constants';
const TEST_TERMINAL_PATH = 'terminal/path';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('EE IDE Terminal', () => {
let wrapper;
let state;
let GLTerminalSpy;
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,
sync: false,
});
};
beforeEach(() => {
GLTerminalSpy = spyOnDependency(Terminal, 'GLTerminal').and.returnValue(
jasmine.createSpyObj('GLTerminal', [
'dispose',
'disable',
'addScrollListener',
'scrollToTop',
'scrollToBottom',
]),
);
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(done => {
factory();
wrapper.vm.createTerminal();
localVue.nextTick(done);
});
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', done => {
expect(wrapper.find(TerminalControls).props()).toEqual({
canScrollUp: false,
canScrollDown: false,
});
wrapper.setData({ canScrollUp: true, canScrollDown: true });
localVue
.nextTick()
.then(() => {
expect(wrapper.find(TerminalControls).props()).toEqual({
canScrollUp: true,
canScrollDown: true,
});
})
.then(done)
.catch(done.fail);
});
});
describe('refresh', () => {
let createTerminal;
let stopTerminal;
beforeEach(() => {
createTerminal = jasmine.createSpy('createTerminal');
stopTerminal = jasmine.createSpy('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(GLTerminalSpy).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.calls.argsFor(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();
});
});
});
......@@ -3,6 +3,7 @@ import Vuex from 'vuex';
import { TEST_HOST } from 'spec/test_constants';
import TerminalEmptyState from 'ee/ide/components/terminal/empty_state.vue';
import TerminalView from 'ee/ide/components/terminal/view.vue';
import TerminalSession from 'ee/ide/components/terminal/session.vue';
const TEST_HELP_PATH = `${TEST_HOST}/help`;
const TEST_SVG_PATH = `${TEST_HOST}/illustration.svg`;
......@@ -42,6 +43,7 @@ describe('EE IDE TerminalView', () => {
actions = {
hideSplash: jasmine.createSpy('hideSplash'),
startSession: jasmine.createSpy('startSession'),
};
getters = {
......@@ -67,13 +69,15 @@ describe('EE IDE TerminalView', () => {
});
});
it('hides splash when started', () => {
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();
});
......@@ -82,6 +86,6 @@ describe('EE IDE TerminalView', () => {
factory();
expect(wrapper.find(TerminalEmptyState).exists()).toBe(false);
expect(wrapper.text()).toContain('Web Terminal');
expect(wrapper.find(TerminalSession).exists()).toBe(true);
});
});
......@@ -6,10 +6,10 @@ import terminalModule from 'ee/ide/stores/modules/terminal';
import extendStore from 'ee/ide/stores/extend';
const TEST_DATASET = {
eeCiYamlHelpPath: `${TEST_HOST}/ci/yaml/help`,
eeCiRunnersHelpPath: `${TEST_HOST}/ci/runners/help`,
eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`,
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);
......@@ -39,10 +39,10 @@ describe('ee/ide/stores/extend', () => {
it('dispatches terminal/setPaths', () => {
expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', {
ciYamlHelpPath: TEST_DATASET.eeCiYamlHelpPath,
ciRunnersHelpPath: TEST_DATASET.eeCiRunnersHelpPath,
webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath,
webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath,
webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath,
webTerminalConfigHelpPath: TEST_DATASET.eeWebTerminalConfigHelpPath,
webTerminalRunnersHelpPath: TEST_DATASET.eeWebTerminalRunnersHelpPath,
});
});
......
......@@ -8,24 +8,37 @@ import * as mutationTypes from 'ee/ide/stores/modules/terminal/mutation_types';
import * as messages from 'ee/ide/stores/modules/terminal/messages';
import * as actions from 'ee/ide/stores/modules/terminal/actions/checks';
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('EE IDE store terminal check actions', () => {
let mock;
let state;
let rootState;
let rootGetters;
beforeEach(() => {
mock = new MockAdapter(axios);
state = {
paths: {
ciYamlHelpPath: TEST_YAML_HELP_PATH,
ciRunnersHelpPath: TEST_RUNNERS_HELP_PATH,
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,
},
};
jasmine.clock().install();
});
......@@ -114,15 +127,39 @@ describe('EE IDE store terminal check actions', () => {
describe('fetchConfigCheck', () => {
it('dispatches request and receive', done => {
mock.onPost(/.*\/ide_terminals\/check_config/).reply(200, {});
testAction(
actions.fetchConfigCheck,
null,
{},
{
...rootGetters,
...rootState,
},
[],
[{ type: 'requestConfigCheck' }, { type: 'receiveConfigCheckSuccess' }],
done,
);
});
it('when error, dispatches request and receive', done => {
mock.onPost(/.*\/ide_terminals\/check_config/).reply(400, {});
testAction(
actions.fetchConfigCheck,
null,
{
...rootGetters,
...rootState,
},
[],
[
{ type: 'requestConfigCheck' },
{ type: 'receiveConfigCheckError', payload: jasmine.any(Error) },
],
done,
);
});
});
describe('requestRunnersCheck', () => {
......@@ -218,14 +255,6 @@ describe('EE IDE store terminal check actions', () => {
});
describe('fetchRunnersCheck', () => {
let rootGetters;
beforeEach(() => {
rootGetters = {
currentProject: { id: 7 },
};
});
it('dispatches request and receive', done => {
mock.onGet(/api\/.*\/projects\/.*\/runners/, { params: { scope: 'active' } }).reply(200, []);
......
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import testAction from 'spec/helpers/vuex_action_helper';
import { STARTING, PENDING, STOPPING, STOPPED } from 'ee/ide/constants';
import * as messages from 'ee/ide/stores/modules/terminal/messages';
import * as mutationTypes from 'ee/ide/stores/modules/terminal/mutation_types';
import actionsModule, * as actions from 'ee/ide/stores/modules/terminal/actions/session_controls';
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',
};
describe('EE IDE store terminal session controls actions', () => {
let mock;
let dispatch;
let rootState;
let rootGetters;
let flashSpy;
beforeEach(() => {
mock = new MockAdapter(axios);
dispatch = jasmine.createSpy('dispatch');
rootState = {
currentBranchId: TEST_BRANCH_ID,
};
rootGetters = {
currentProject: {
id: 7,
path_with_namespace: TEST_PROJECT_PATH,
},
};
flashSpy = spyOnDependency(actionsModule, 'flash');
});
afterEach(() => {
mock.restore();
});
describe('requestStartSession', () => {
it('sets session status', done => {
testAction(
actions.requestStartSession,
null,
{},
[{ type: mutationTypes.SET_SESSION_STATUS, payload: STARTING }],
[],
done,
);
});
});
describe('receiveStartSessionSuccess', () => {
it('sets session and starts polling status', done => {
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,
},
},
],
[{ type: 'pollSessionStatus' }],
done,
);
});
});
describe('receiveStartSessionError', () => {
it('flashes message', () => {
actions.receiveStartSessionError({ dispatch });
expect(flashSpy).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STARTING);
});
it('sets session status', done => {
testAction(actions.receiveStartSessionError, null, {}, [], [{ type: 'killSession' }], done);
});
});
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', done => {
mock.onPost(/.*\/ide_terminals/).reply(200, TEST_SESSION);
testAction(
actions.startSession,
null,
{ ...rootGetters, ...rootState },
[],
[
{ type: 'requestStartSession' },
{ type: 'receiveStartSessionSuccess', payload: TEST_SESSION },
],
done,
);
});
it('dispatches request and receive on error', done => {
mock.onPost(/.*\/ide_terminals/).reply(400);
testAction(
actions.startSession,
null,
{ ...rootGetters, ...rootState },
[],
[
{ type: 'requestStartSession' },
{ type: 'receiveStartSessionError', payload: jasmine.any(Error) },
],
done,
);
});
});
describe('requestStopSession', () => {
it('sets session status', done => {
testAction(
actions.requestStopSession,
null,
{},
[{ type: mutationTypes.SET_SESSION_STATUS, payload: STOPPING }],
[],
done,
);
});
});
describe('receiveStopSessionSuccess', () => {
it('kills the session', done => {
testAction(actions.receiveStopSessionSuccess, null, {}, [], [{ type: 'killSession' }], done);
});
});
describe('receiveStopSessionError', () => {
it('flashes message', () => {
actions.receiveStopSessionError({ dispatch });
expect(flashSpy).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STOPPING);
});
it('kills the session', done => {
testAction(actions.receiveStopSessionError, null, {}, [], [{ type: 'killSession' }], done);
});
});
describe('stopSession', () => {
it('dispatches request and receive on success', done => {
mock.onPost(TEST_SESSION.cancel_path).reply(200, {});
const state = {
session: { cancelPath: TEST_SESSION.cancel_path },
};
testAction(
actions.stopSession,
null,
state,
[],
[{ type: 'requestStopSession' }, { type: 'receiveStopSessionSuccess' }],
done,
);
});
it('dispatches request and receive on error', done => {
mock.onPost(TEST_SESSION.cancel_path).reply(400);
const state = {
session: { cancelPath: TEST_SESSION.cancel_path },
};
testAction(
actions.stopSession,
null,
state,
[],
[
{ type: 'requestStopSession' },
{ type: 'receiveStopSessionError', payload: jasmine.any(Error) },
],
done,
);
});
});
describe('killSession', () => {
it('stops polling and sets status', done => {
testAction(
actions.killSession,
null,
{},
[{ type: mutationTypes.SET_SESSION_STATUS, payload: STOPPED }],
[{ type: 'stopPollingSessionStatus' }],
done,
);
});
});
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', done => {
state.session.retryPath = '';
testAction(
actions.restartSession,
null,
{ ...state, ...rootState },
[],
[{ type: 'startSession' }],
done,
);
});
it('dispatches request and receive on success', done => {
mock
.onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' })
.reply(200, TEST_SESSION);
testAction(
actions.restartSession,
null,
{ ...state, ...rootState },
[],
[
{ type: 'requestStartSession' },
{ type: 'receiveStartSessionSuccess', payload: TEST_SESSION },
],
done,
);
});
it('dispatches request and receive on error', done => {
mock
.onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' })
.reply(400);
testAction(
actions.restartSession,
null,
{ ...state, ...rootState },
[],
[
{ type: 'requestStartSession' },
{ type: 'receiveStartSessionError', payload: jasmine.any(Error) },
],
done,
);
});
[httpStatus.NOT_FOUND, httpStatus.UNPROCESSABLE_ENTITY].forEach(status => {
it(`dispatches request and startSession on ${status}`, done => {
mock
.onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' })
.reply(status);
testAction(
actions.restartSession,
null,
{ ...state, ...rootState },
[],
[{ type: 'requestStartSession' }, { type: 'startSession' }],
done,
);
});
});
});
});
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import testAction from 'spec/helpers/vuex_action_helper';
import { PENDING, RUNNING, STOPPING, STOPPED } from 'ee/ide/constants';
import * as messages from 'ee/ide/stores/modules/terminal/messages';
import * as mutationTypes from 'ee/ide/stores/modules/terminal/mutation_types';
import actionsModule, * as actions from 'ee/ide/stores/modules/terminal/actions/session_status';
const TEST_SESSION = {
id: 7,
status: PENDING,
show_path: 'path/show',
cancel_path: 'path/cancel',
retry_path: 'path/retry',
terminal_path: 'path/terminal',
};
describe('EE IDE store terminal session controls actions', () => {
let mock;
let dispatch;
let commit;
let flashSpy;
beforeEach(() => {
jasmine.clock().install();
mock = new MockAdapter(axios);
dispatch = jasmine.createSpy('dispatch');
commit = jasmine.createSpy('commit');
flashSpy = spyOnDependency(actionsModule, 'flash');
});
afterEach(() => {
jasmine.clock().uninstall();
mock.restore();
});
describe('pollSessionStatus', () => {
it('starts interval to poll status', done => {
testAction(
actions.pollSessionStatus,
null,
{},
[{ type: mutationTypes.SET_SESSION_STATUS_INTERVAL, payload: jasmine.any(Number) }],
[{ type: 'stopPollingSessionStatus' }, { type: 'fetchSessionStatus' }],
done,
);
});
it('on interval, stops polling if no session', () => {
const state = {
session: null,
};
actions.pollSessionStatus({ state, dispatch, commit });
dispatch.calls.reset();
jasmine.clock().tick(5001);
expect(dispatch).toHaveBeenCalledWith('stopPollingSessionStatus');
});
it('on interval, fetches status', () => {
const state = {
session: TEST_SESSION,
};
actions.pollSessionStatus({ state, dispatch, commit });
dispatch.calls.reset();
jasmine.clock().tick(5001);
expect(dispatch).toHaveBeenCalledWith('fetchSessionStatus');
});
});
describe('stopPollingSessionStatus', () => {
it('does nothing if sessionStatusInterval is empty', done => {
testAction(actions.stopPollingSessionStatus, null, {}, [], [], done);
});
it('clears interval', done => {
testAction(
actions.stopPollingSessionStatus,
null,
{ sessionStatusInterval: 7 },
[{ type: mutationTypes.SET_SESSION_STATUS_INTERVAL, payload: 0 }],
[],
done,
);
});
});
describe('receiveSessionStatusSuccess', () => {
it('sets session status', done => {
testAction(
actions.receiveSessionStatusSuccess,
{ status: RUNNING },
{},
[{ type: mutationTypes.SET_SESSION_STATUS, payload: RUNNING }],
[],
done,
);
});
[STOPPING, STOPPED, 'unexpected'].forEach(status => {
it(`kills session if status is ${status}`, done => {
testAction(
actions.receiveSessionStatusSuccess,
{ status },
{},
[{ type: mutationTypes.SET_SESSION_STATUS, payload: status }],
[{ type: 'killSession' }],
done,
);
});
});
});
describe('receiveSessionStatusError', () => {
it('flashes message', () => {
actions.receiveSessionStatusError({ dispatch });
expect(flashSpy).toHaveBeenCalledWith(messages.UNEXPECTED_ERROR_STATUS);
});
it('kills the session', done => {
testAction(actions.receiveSessionStatusError, null, {}, [], [{ type: 'killSession' }], done);
});
});
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', done => {
mock.onGet(state.session.showPath).reply(200, TEST_SESSION);
testAction(
actions.fetchSessionStatus,
null,
state,
[],
[{ type: 'receiveSessionStatusSuccess', payload: TEST_SESSION }],
done,
);
});
it('dispatches error on error', done => {
mock.onGet(state.session.showPath).reply(400);
testAction(
actions.fetchSessionStatus,
null,
state,
[],
[{ type: 'receiveSessionStatusError', payload: jasmine.any(Error) }],
done,
);
});
});
});
......@@ -3,6 +3,19 @@ import * as mutationTypes from 'ee/ide/stores/modules/terminal/mutation_types';
import * as actions from 'ee/ide/stores/modules/terminal/actions/setup';
describe('EE IDE store terminal setup actions', () => {
describe('init', () => {
it('dispatches checks', done => {
testAction(
actions.init,
null,
{},
[],
[{ type: 'fetchConfigCheck' }, { type: 'fetchRunnersCheck' }],
done,
);
});
});
describe('hideSplash', () => {
it('commits HIDE_SPLASH', done => {
testAction(actions.hideSplash, null, {}, [{ type: mutationTypes.HIDE_SPLASH }], [], done);
......
import { CHECK_CONFIG, CHECK_RUNNERS } from 'ee/ide/constants';
import { CHECK_CONFIG, CHECK_RUNNERS, RUNNING, STOPPING } from 'ee/ide/constants';
import createState from 'ee/ide/stores/modules/terminal/state';
import * as types from 'ee/ide/stores/modules/terminal/mutation_types';
import mutations from 'ee/ide/stores/modules/terminal/mutations';
......@@ -85,4 +85,53 @@ describe('EE IDE store terminal mutations', () => {
});
});
});
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);
});
});
});
......@@ -776,6 +776,15 @@ msgstr ""
msgid "An unexpected error occurred while checking the project runners."
msgstr ""
msgid "An unexpected error occurred while communicating with the Web Terminal."
msgstr ""
msgid "An unexpected error occurred while starting the Web Terminal."
msgstr ""
msgid "An unexpected error occurred while stopping the Web Terminal."
msgstr ""
msgid "Analytics"
msgstr ""
......@@ -2291,7 +2300,7 @@ msgstr ""
msgid "Confidentiality"
msgstr ""
msgid "Configure GitLab runners to start using Web Terminal. %{helpStart}Learn more.%{helpEnd}"
msgid "Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}"
msgstr ""
msgid "Configure Gitaly timeouts."
......@@ -7242,6 +7251,9 @@ msgstr ""
msgid "Response metrics (NGINX)"
msgstr ""
msgid "Restart Terminal"
msgstr ""
msgid "Resume"
msgstr ""
......@@ -8101,6 +8113,9 @@ msgstr ""
msgid "Started"
msgstr ""
msgid "Starting..."
msgstr ""
msgid "Starts at (UTC)"
msgstr ""
......@@ -8110,6 +8125,9 @@ msgstr ""
msgid "Status"
msgstr ""
msgid "Stop Terminal"
msgstr ""
msgid "Stop environment"
msgstr ""
......@@ -8125,6 +8143,9 @@ msgstr ""
msgid "Stopping this environment is currently not possible as a deployment is in progress"
msgstr ""
msgid "Stopping..."
msgstr ""
msgid "Storage"
msgstr ""
......
import { addClassIfElementExists } from '~/lib/utils/dom_utils';
import { addClassIfElementExists, canScrollUp, canScrollDown } from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
describe('DOM Utils', () => {
describe('addClassIfElementExists', () => {
......@@ -34,4 +36,54 @@ describe('DOM Utils', () => {
addClassIfElementExists(childElement, className);
});
});
describe('canScrollUp', () => {
[1, 100].forEach(scrollTop => {
it(`is true if scrollTop is > 0 (${scrollTop})`, () => {
expect(canScrollUp({ scrollTop })).toBe(true);
});
});
[0, -10].forEach(scrollTop => {
it(`is false if scrollTop is <= 0 (${scrollTop})`, () => {
expect(canScrollUp({ scrollTop })).toBe(false);
});
});
it('is true if scrollTop is > margin', () => {
expect(canScrollUp({ scrollTop: TEST_MARGIN + 1 }, TEST_MARGIN)).toBe(true);
});
it('is false if scrollTop is <= margin', () => {
expect(canScrollUp({ scrollTop: TEST_MARGIN }, TEST_MARGIN)).toBe(false);
});
});
describe('canScrollDown', () => {
let element;
beforeEach(() => {
element = { scrollTop: 7, offsetHeight: 22, scrollHeight: 30 };
});
it('is true if element can be scrolled down', () => {
expect(canScrollDown(element)).toBe(true);
});
it('is false if element cannot be scrolled down', () => {
element.scrollHeight -= 1;
expect(canScrollDown(element)).toBe(false);
});
it('is true if element can be scrolled down, with margin given', () => {
element.scrollHeight += TEST_MARGIN;
expect(canScrollDown(element, TEST_MARGIN)).toBe(true);
});
it('is false if element cannot be scrolled down, with margin given', () => {
expect(canScrollDown(element, TEST_MARGIN)).toBe(false);
});
});
});
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