Merge branch 'ide-jobs-log' into 'master'
Show job logs in web IDE Closes #46245 See merge request gitlab-org/gitlab-ce!19279
This commit is contained in:
commit
bb6b73cf3c
22 changed files with 823 additions and 26 deletions
136
app/assets/javascripts/ide/components/jobs/detail.vue
Normal file
136
app/assets/javascripts/ide/components/jobs/detail.vue
Normal file
|
@ -0,0 +1,136 @@
|
|||
<script>
|
||||
import { mapActions, mapState } from 'vuex';
|
||||
import _ from 'underscore';
|
||||
import { __ } from '../../../locale';
|
||||
import tooltip from '../../../vue_shared/directives/tooltip';
|
||||
import Icon from '../../../vue_shared/components/icon.vue';
|
||||
import ScrollButton from './detail/scroll_button.vue';
|
||||
import JobDescription from './detail/description.vue';
|
||||
|
||||
const scrollPositions = {
|
||||
top: 0,
|
||||
bottom: 1,
|
||||
};
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
components: {
|
||||
Icon,
|
||||
ScrollButton,
|
||||
JobDescription,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrollPos: scrollPositions.top,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState('pipelines', ['detailJob']),
|
||||
isScrolledToBottom() {
|
||||
return this.scrollPos === scrollPositions.bottom;
|
||||
},
|
||||
isScrolledToTop() {
|
||||
return this.scrollPos === scrollPositions.top;
|
||||
},
|
||||
jobOutput() {
|
||||
return this.detailJob.output || __('No messages were logged');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getTrace();
|
||||
},
|
||||
methods: {
|
||||
...mapActions('pipelines', ['fetchJobTrace', 'setDetailJob']),
|
||||
scrollDown() {
|
||||
if (this.$refs.buildTrace) {
|
||||
this.$refs.buildTrace.scrollTo(0, this.$refs.buildTrace.scrollHeight);
|
||||
}
|
||||
},
|
||||
scrollUp() {
|
||||
if (this.$refs.buildTrace) {
|
||||
this.$refs.buildTrace.scrollTo(0, 0);
|
||||
}
|
||||
},
|
||||
scrollBuildLog: _.throttle(function buildLogScrollDebounce() {
|
||||
const { scrollTop } = this.$refs.buildTrace;
|
||||
const { offsetHeight, scrollHeight } = this.$refs.buildTrace;
|
||||
|
||||
if (scrollTop + offsetHeight === scrollHeight) {
|
||||
this.scrollPos = scrollPositions.bottom;
|
||||
} else if (scrollTop === 0) {
|
||||
this.scrollPos = scrollPositions.top;
|
||||
} else {
|
||||
this.scrollPos = '';
|
||||
}
|
||||
}),
|
||||
getTrace() {
|
||||
return this.fetchJobTrace().then(() => this.scrollDown());
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ide-pipeline build-page d-flex flex-column flex-fill">
|
||||
<header class="ide-job-header d-flex align-items-center">
|
||||
<button
|
||||
class="btn btn-default btn-sm d-flex"
|
||||
@click="setDetailJob(null)"
|
||||
>
|
||||
<icon
|
||||
name="chevron-left"
|
||||
/>
|
||||
{{ __('View jobs') }}
|
||||
</button>
|
||||
</header>
|
||||
<div class="top-bar d-flex border-left-0">
|
||||
<job-description
|
||||
:job="detailJob"
|
||||
/>
|
||||
<div class="controllers ml-auto">
|
||||
<a
|
||||
v-tooltip
|
||||
:title="__('Show complete raw log')"
|
||||
data-placement="top"
|
||||
data-container="body"
|
||||
class="controllers-buttons"
|
||||
:href="detailJob.rawPath"
|
||||
target="_blank"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="fa fa-file-text-o"
|
||||
></i>
|
||||
</a>
|
||||
<scroll-button
|
||||
direction="up"
|
||||
:disabled="isScrolledToTop"
|
||||
@click="scrollUp"
|
||||
/>
|
||||
<scroll-button
|
||||
direction="down"
|
||||
:disabled="isScrolledToBottom"
|
||||
@click="scrollDown"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
class="build-trace mb-0 h-100"
|
||||
ref="buildTrace"
|
||||
@scroll="scrollBuildLog"
|
||||
>
|
||||
<code
|
||||
class="bash"
|
||||
v-html="jobOutput"
|
||||
>
|
||||
</code>
|
||||
<div
|
||||
v-show="detailJob.isLoading"
|
||||
class="build-loader-animation"
|
||||
>
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import Icon from '../../../../vue_shared/components/icon.vue';
|
||||
import CiIcon from '../../../../vue_shared/components/ci_icon.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
CiIcon,
|
||||
},
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
jobId() {
|
||||
return `#${this.job.id}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex align-items-center">
|
||||
<ci-icon
|
||||
class="d-flex"
|
||||
:status="job.status"
|
||||
:borderless="true"
|
||||
:size="24"
|
||||
/>
|
||||
<span class="prepend-left-8">
|
||||
{{ job.name }}
|
||||
<a
|
||||
:href="job.path"
|
||||
target="_blank"
|
||||
class="ide-external-link"
|
||||
>
|
||||
{{ jobId }}
|
||||
<icon
|
||||
name="external-link"
|
||||
:size="12"
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,66 @@
|
|||
<script>
|
||||
import { __ } from '../../../../locale';
|
||||
import Icon from '../../../../vue_shared/components/icon.vue';
|
||||
import tooltip from '../../../../vue_shared/directives/tooltip';
|
||||
|
||||
const directions = {
|
||||
up: 'up',
|
||||
down: 'down',
|
||||
};
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
tooltip,
|
||||
},
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
direction: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator(value) {
|
||||
return Object.keys(directions).includes(value);
|
||||
},
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
tooltipTitle() {
|
||||
return this.direction === directions.up ? __('Scroll to top') : __('Scroll to bottom');
|
||||
},
|
||||
iconName() {
|
||||
return `scroll_${this.direction}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
clickedScroll() {
|
||||
this.$emit('click');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-tooltip
|
||||
class="controllers-buttons"
|
||||
data-container="body"
|
||||
data-placement="top"
|
||||
:title="tooltipTitle"
|
||||
>
|
||||
<button
|
||||
class="btn-scroll btn-transparent btn-blank"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="clickedScroll"
|
||||
>
|
||||
<icon
|
||||
:name="iconName"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
|
@ -1,11 +1,9 @@
|
|||
<script>
|
||||
import Icon from '../../../vue_shared/components/icon.vue';
|
||||
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
|
||||
import JobDescription from './detail/description.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon,
|
||||
CiIcon,
|
||||
JobDescription,
|
||||
},
|
||||
props: {
|
||||
job: {
|
||||
|
@ -18,29 +16,29 @@ export default {
|
|||
return `#${this.job.id}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
clickViewLog() {
|
||||
this.$emit('clickViewLog', this.job);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ide-job-item">
|
||||
<ci-icon
|
||||
:status="job.status"
|
||||
:borderless="true"
|
||||
:size="24"
|
||||
<job-description
|
||||
class="append-right-default"
|
||||
:job="job"
|
||||
/>
|
||||
<span class="prepend-left-8">
|
||||
{{ job.name }}
|
||||
<a
|
||||
:href="job.path"
|
||||
target="_blank"
|
||||
class="ide-external-link"
|
||||
<div class="ml-auto align-self-center">
|
||||
<button
|
||||
v-if="job.started"
|
||||
type="button"
|
||||
class="btn btn-default btn-sm"
|
||||
@click="clickViewLog"
|
||||
>
|
||||
{{ jobId }}
|
||||
<icon
|
||||
name="external-link"
|
||||
:size="12"
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
{{ __('View log') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -19,7 +19,7 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed']),
|
||||
...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed', 'setDetailJob']),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -38,6 +38,7 @@ export default {
|
|||
:stage="stage"
|
||||
@fetch="fetchJobs"
|
||||
@toggleCollapsed="toggleStageCollapsed"
|
||||
@clickViewLog="setDetailJob"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -48,6 +48,9 @@ export default {
|
|||
toggleCollapsed() {
|
||||
this.$emit('toggleCollapsed', this.stage.id);
|
||||
},
|
||||
clickViewLog(job) {
|
||||
this.$emit('clickViewLog', job);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -101,6 +104,7 @@ export default {
|
|||
v-for="job in stage.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
@clickViewLog="clickViewLog"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,7 @@ import tooltip from '../../../vue_shared/directives/tooltip';
|
|||
import Icon from '../../../vue_shared/components/icon.vue';
|
||||
import { rightSidebarViews } from '../../constants';
|
||||
import PipelinesList from '../pipelines/list.vue';
|
||||
import JobsDetail from '../jobs/detail.vue';
|
||||
|
||||
export default {
|
||||
directives: {
|
||||
|
@ -12,9 +13,16 @@ export default {
|
|||
components: {
|
||||
Icon,
|
||||
PipelinesList,
|
||||
JobsDetail,
|
||||
},
|
||||
computed: {
|
||||
...mapState(['rightPane']),
|
||||
pipelinesActive() {
|
||||
return (
|
||||
this.rightPane === rightSidebarViews.pipelines ||
|
||||
this.rightPane === rightSidebarViews.jobsDetail
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['setRightPane']),
|
||||
|
@ -48,7 +56,7 @@ export default {
|
|||
:title="__('Pipelines')"
|
||||
class="ide-sidebar-link is-right"
|
||||
:class="{
|
||||
active: rightPane === $options.rightSidebarViews.pipelines
|
||||
active: pipelinesActive
|
||||
}"
|
||||
type="button"
|
||||
@click="clickTab($event, $options.rightSidebarViews.pipelines)"
|
||||
|
|
|
@ -23,4 +23,5 @@ export const viewerTypes = {
|
|||
|
||||
export const rightSidebarViews = {
|
||||
pipelines: 'pipelines-list',
|
||||
jobsDetail: 'jobs-detail',
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@ import { __ } from '../../../../locale';
|
|||
import flash from '../../../../flash';
|
||||
import Poll from '../../../../lib/utils/poll';
|
||||
import service from '../../../services';
|
||||
import { rightSidebarViews } from '../../../constants';
|
||||
import * as types from './mutation_types';
|
||||
|
||||
let eTagPoll;
|
||||
|
@ -77,4 +78,28 @@ export const fetchJobs = ({ dispatch }, stage) => {
|
|||
export const toggleStageCollapsed = ({ commit }, stageId) =>
|
||||
commit(types.TOGGLE_STAGE_COLLAPSE, stageId);
|
||||
|
||||
export const setDetailJob = ({ commit, dispatch }, job) => {
|
||||
commit(types.SET_DETAIL_JOB, job);
|
||||
dispatch('setRightPane', job ? rightSidebarViews.jobsDetail : rightSidebarViews.pipelines, {
|
||||
root: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const requestJobTrace = ({ commit }) => commit(types.REQUEST_JOB_TRACE);
|
||||
export const receiveJobTraceError = ({ commit }) => {
|
||||
flash(__('Error fetching job trace'));
|
||||
commit(types.RECEIVE_JOB_TRACE_ERROR);
|
||||
};
|
||||
export const receiveJobTraceSuccess = ({ commit }, data) =>
|
||||
commit(types.RECEIVE_JOB_TRACE_SUCCESS, data);
|
||||
|
||||
export const fetchJobTrace = ({ dispatch, state }) => {
|
||||
dispatch('requestJobTrace');
|
||||
|
||||
return axios
|
||||
.get(`${state.detailJob.path}/trace`, { params: { format: 'json' } })
|
||||
.then(({ data }) => dispatch('receiveJobTraceSuccess', data))
|
||||
.catch(() => dispatch('receiveJobTraceError'));
|
||||
};
|
||||
|
||||
export default () => {};
|
||||
|
|
|
@ -7,3 +7,9 @@ export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR';
|
|||
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
|
||||
|
||||
export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE';
|
||||
|
||||
export const SET_DETAIL_JOB = 'SET_DETAIL_JOB';
|
||||
|
||||
export const REQUEST_JOB_TRACE = 'REQUEST_JOB_TRACE';
|
||||
export const RECEIVE_JOB_TRACE_ERROR = 'RECEIVE_JOB_TRACE_ERROR';
|
||||
export const RECEIVE_JOB_TRACE_SUCCESS = 'RECEIVE_JOB_TRACE_SUCCESS';
|
||||
|
|
|
@ -63,4 +63,17 @@ export default {
|
|||
isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed,
|
||||
}));
|
||||
},
|
||||
[types.SET_DETAIL_JOB](state, job) {
|
||||
state.detailJob = { ...job };
|
||||
},
|
||||
[types.REQUEST_JOB_TRACE](state) {
|
||||
state.detailJob.isLoading = true;
|
||||
},
|
||||
[types.RECEIVE_JOB_TRACE_ERROR](state) {
|
||||
state.detailJob.isLoading = false;
|
||||
},
|
||||
[types.RECEIVE_JOB_TRACE_SUCCESS](state, data) {
|
||||
state.detailJob.isLoading = false;
|
||||
state.detailJob.output = data.html;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -3,4 +3,5 @@ export default () => ({
|
|||
isLoadingJobs: false,
|
||||
latestPipeline: null,
|
||||
stages: [],
|
||||
detailJob: null,
|
||||
});
|
||||
|
|
|
@ -4,4 +4,8 @@ export const normalizeJob = job => ({
|
|||
name: job.name,
|
||||
status: job.status,
|
||||
path: job.build_path,
|
||||
rawPath: `${job.build_path}/raw`,
|
||||
started: job.started,
|
||||
output: '',
|
||||
isLoading: false,
|
||||
});
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
|
||||
.top-bar {
|
||||
height: 35px;
|
||||
min-height: 35px;
|
||||
background: $gray-light;
|
||||
border: 1px solid $border-color;
|
||||
color: $gl-text-color;
|
||||
|
|
|
@ -1146,8 +1146,13 @@
|
|||
}
|
||||
|
||||
.ide-external-link {
|
||||
position: relative;
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: -$gl-padding;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
|
@ -1178,6 +1183,8 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin-top: -$grid-size;
|
||||
margin-bottom: -$grid-size;
|
||||
|
||||
.empty-state {
|
||||
margin-top: auto;
|
||||
|
@ -1194,6 +1201,17 @@
|
|||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.build-trace,
|
||||
.top-bar {
|
||||
margin-left: -$gl-padding;
|
||||
}
|
||||
|
||||
&.build-page .top-bar {
|
||||
top: 0;
|
||||
font-size: 12px;
|
||||
border-top-right-radius: $border-radius-default;
|
||||
}
|
||||
}
|
||||
|
||||
.ide-pipeline-list {
|
||||
|
@ -1202,7 +1220,7 @@
|
|||
}
|
||||
|
||||
.ide-pipeline-header {
|
||||
min-height: 50px;
|
||||
min-height: 55px;
|
||||
padding-left: $gl-padding;
|
||||
padding-right: $gl-padding;
|
||||
|
||||
|
@ -1222,8 +1240,7 @@
|
|||
.ci-status-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
margin-top: -2px;
|
||||
min-width: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
@ -1253,3 +1270,7 @@
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ide-job-header {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import Vue from 'vue';
|
||||
import Description from '~/ide/components/jobs/detail/description.vue';
|
||||
import mountComponent from '../../../../helpers/vue_mount_component_helper';
|
||||
import { jobs } from '../../../mock_data';
|
||||
|
||||
describe('IDE job description', () => {
|
||||
const Component = Vue.extend(Description);
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, {
|
||||
job: jobs[0],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('renders job details', () => {
|
||||
expect(vm.$el.textContent).toContain('#1');
|
||||
expect(vm.$el.textContent).toContain('test');
|
||||
});
|
||||
|
||||
it('renders CI icon', () => {
|
||||
expect(vm.$el.querySelector('.ci-status-icon .ic-status_passed_borderless')).not.toBe(null);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
import Vue from 'vue';
|
||||
import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
|
||||
import mountComponent from '../../../../helpers/vue_mount_component_helper';
|
||||
|
||||
describe('IDE job log scroll button', () => {
|
||||
const Component = Vue.extend(ScrollButton);
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, {
|
||||
direction: 'up',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('iconName', () => {
|
||||
['up', 'down'].forEach(direction => {
|
||||
it(`returns icon name for ${direction}`, () => {
|
||||
vm.direction = direction;
|
||||
|
||||
expect(vm.iconName).toBe(`scroll_${direction}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltipTitle', () => {
|
||||
it('returns title for up', () => {
|
||||
expect(vm.tooltipTitle).toBe('Scroll to top');
|
||||
});
|
||||
|
||||
it('returns title for down', () => {
|
||||
vm.direction = 'down';
|
||||
|
||||
expect(vm.tooltipTitle).toBe('Scroll to bottom');
|
||||
});
|
||||
});
|
||||
|
||||
it('emits click event on click', () => {
|
||||
spyOn(vm, '$emit');
|
||||
|
||||
vm.$el.querySelector('.btn-scroll').click();
|
||||
|
||||
expect(vm.$emit).toHaveBeenCalledWith('click');
|
||||
});
|
||||
|
||||
it('disables button when disabled is true', done => {
|
||||
vm.disabled = true;
|
||||
|
||||
vm.$nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn-scroll').hasAttribute('disabled')).toBe(true);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
180
spec/javascripts/ide/components/jobs/detail_spec.js
Normal file
180
spec/javascripts/ide/components/jobs/detail_spec.js
Normal file
|
@ -0,0 +1,180 @@
|
|||
import Vue from 'vue';
|
||||
import JobDetail from '~/ide/components/jobs/detail.vue';
|
||||
import { createStore } from '~/ide/stores';
|
||||
import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper';
|
||||
import { jobs } from '../../mock_data';
|
||||
|
||||
describe('IDE jobs detail view', () => {
|
||||
const Component = Vue.extend(JobDetail);
|
||||
let vm;
|
||||
|
||||
beforeEach(() => {
|
||||
const store = createStore();
|
||||
|
||||
store.state.pipelines.detailJob = {
|
||||
...jobs[0],
|
||||
isLoading: true,
|
||||
output: 'testing',
|
||||
rawPath: `${gl.TEST_HOST}/raw`,
|
||||
};
|
||||
|
||||
vm = createComponentWithStore(Component, store);
|
||||
|
||||
spyOn(vm, 'fetchJobTrace').and.returnValue(Promise.resolve());
|
||||
|
||||
vm = vm.$mount();
|
||||
|
||||
spyOn(vm.$refs.buildTrace, 'scrollTo');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
it('calls fetchJobTrace on mount', () => {
|
||||
expect(vm.fetchJobTrace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls to bottom on mount', done => {
|
||||
setTimeout(() => {
|
||||
expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders job output', () => {
|
||||
expect(vm.$el.querySelector('.bash').textContent).toContain('testing');
|
||||
});
|
||||
|
||||
it('renders empty message output', done => {
|
||||
vm.$store.state.pipelines.detailJob.output = '';
|
||||
|
||||
vm.$nextTick(() => {
|
||||
expect(vm.$el.querySelector('.bash').textContent).toContain('No messages were logged');
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders loading icon', () => {
|
||||
expect(vm.$el.querySelector('.build-loader-animation')).not.toBe(null);
|
||||
expect(vm.$el.querySelector('.build-loader-animation').style.display).toBe('');
|
||||
});
|
||||
|
||||
it('hide loading icon when isLoading is false', done => {
|
||||
vm.$store.state.pipelines.detailJob.isLoading = false;
|
||||
|
||||
vm.$nextTick(() => {
|
||||
expect(vm.$el.querySelector('.build-loader-animation').style.display).toBe('none');
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets detailJob when clicking header button', () => {
|
||||
spyOn(vm, 'setDetailJob');
|
||||
|
||||
vm.$el.querySelector('.btn').click();
|
||||
|
||||
expect(vm.setDetailJob).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('renders raw path link', () => {
|
||||
expect(vm.$el.querySelector('.controllers-buttons').getAttribute('href')).toBe(
|
||||
`${gl.TEST_HOST}/raw`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('scroll buttons', () => {
|
||||
it('triggers scrollDown when clicking down button', done => {
|
||||
spyOn(vm, 'scrollDown');
|
||||
|
||||
vm.$el.querySelectorAll('.btn-scroll')[1].click();
|
||||
|
||||
vm.$nextTick(() => {
|
||||
expect(vm.scrollDown).toHaveBeenCalled();
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('triggers scrollUp when clicking up button', done => {
|
||||
spyOn(vm, 'scrollUp');
|
||||
|
||||
vm.scrollPos = 1;
|
||||
|
||||
vm
|
||||
.$nextTick()
|
||||
.then(() => vm.$el.querySelector('.btn-scroll').click())
|
||||
.then(() => vm.$nextTick())
|
||||
.then(() => {
|
||||
expect(vm.scrollUp).toHaveBeenCalled();
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollDown', () => {
|
||||
it('scrolls build trace to bottom', () => {
|
||||
spyOnProperty(vm.$refs.buildTrace, 'scrollHeight').and.returnValue(1000);
|
||||
|
||||
vm.scrollDown();
|
||||
|
||||
expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollUp', () => {
|
||||
it('scrolls build trace to top', () => {
|
||||
vm.scrollUp();
|
||||
|
||||
expect(vm.$refs.buildTrace.scrollTo).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollBuildLog', () => {
|
||||
beforeEach(() => {
|
||||
spyOnProperty(vm.$refs.buildTrace, 'offsetHeight').and.returnValue(100);
|
||||
spyOnProperty(vm.$refs.buildTrace, 'scrollHeight').and.returnValue(200);
|
||||
});
|
||||
|
||||
it('sets scrollPos to bottom when at the bottom', done => {
|
||||
spyOnProperty(vm.$refs.buildTrace, 'scrollTop').and.returnValue(100);
|
||||
|
||||
vm.scrollBuildLog();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(vm.scrollPos).toBe(1);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('sets scrollPos to top when at the top', done => {
|
||||
spyOnProperty(vm.$refs.buildTrace, 'scrollTop').and.returnValue(0);
|
||||
vm.scrollPos = 1;
|
||||
|
||||
vm.scrollBuildLog();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(vm.scrollPos).toBe(0);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('resets scrollPos when not at top or bottom', done => {
|
||||
spyOnProperty(vm.$refs.buildTrace, 'scrollTop').and.returnValue(10);
|
||||
|
||||
vm.scrollBuildLog();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(vm.scrollPos).toBe('');
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -26,4 +26,14 @@ describe('IDE jobs item', () => {
|
|||
it('renders CI icon', () => {
|
||||
expect(vm.$el.querySelector('.ic-status_passed_borderless')).not.toBe(null);
|
||||
});
|
||||
|
||||
it('does not render view logs button if not started', done => {
|
||||
vm.job.started = false;
|
||||
|
||||
vm.$nextTick(() => {
|
||||
expect(vm.$el.querySelector('.btn')).toBe(null);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -75,6 +75,7 @@ export const jobs = [
|
|||
},
|
||||
stage: 'test',
|
||||
duration: 1,
|
||||
started: new Date(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
|
@ -86,6 +87,7 @@ export const jobs = [
|
|||
},
|
||||
stage: 'test',
|
||||
duration: 1,
|
||||
started: new Date(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
|
@ -97,6 +99,7 @@ export const jobs = [
|
|||
},
|
||||
stage: 'test',
|
||||
duration: 1,
|
||||
started: new Date(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
|
@ -108,6 +111,7 @@ export const jobs = [
|
|||
},
|
||||
stage: 'build',
|
||||
duration: 1,
|
||||
started: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -13,9 +13,15 @@ import actions, {
|
|||
receiveJobsSuccess,
|
||||
fetchJobs,
|
||||
toggleStageCollapsed,
|
||||
setDetailJob,
|
||||
requestJobTrace,
|
||||
receiveJobTraceError,
|
||||
receiveJobTraceSuccess,
|
||||
fetchJobTrace,
|
||||
} from '~/ide/stores/modules/pipelines/actions';
|
||||
import state from '~/ide/stores/modules/pipelines/state';
|
||||
import * as types from '~/ide/stores/modules/pipelines/mutation_types';
|
||||
import { rightSidebarViews } from '~/ide/constants';
|
||||
import testAction from '../../../../helpers/vuex_action_helper';
|
||||
import { pipelines, jobs } from '../../../mock_data';
|
||||
|
||||
|
@ -281,4 +287,133 @@ describe('IDE pipelines actions', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDetailJob', () => {
|
||||
it('commits job', done => {
|
||||
testAction(
|
||||
setDetailJob,
|
||||
'job',
|
||||
mockedState,
|
||||
[{ type: types.SET_DETAIL_JOB, payload: 'job' }],
|
||||
[{ type: 'setRightPane' }],
|
||||
done,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches setRightPane as pipeline when job is null', done => {
|
||||
testAction(
|
||||
setDetailJob,
|
||||
null,
|
||||
mockedState,
|
||||
[{ type: types.SET_DETAIL_JOB }],
|
||||
[{ type: 'setRightPane', payload: rightSidebarViews.pipelines }],
|
||||
done,
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches setRightPane as job', done => {
|
||||
testAction(
|
||||
setDetailJob,
|
||||
'job',
|
||||
mockedState,
|
||||
[{ type: types.SET_DETAIL_JOB }],
|
||||
[{ type: 'setRightPane', payload: rightSidebarViews.jobsDetail }],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestJobTrace', () => {
|
||||
it('commits request', done => {
|
||||
testAction(requestJobTrace, null, mockedState, [{ type: types.REQUEST_JOB_TRACE }], [], done);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveJobTraceError', () => {
|
||||
it('commits error', done => {
|
||||
testAction(
|
||||
receiveJobTraceError,
|
||||
null,
|
||||
mockedState,
|
||||
[{ type: types.RECEIVE_JOB_TRACE_ERROR }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates flash message', () => {
|
||||
const flashSpy = spyOnDependency(actions, 'flash');
|
||||
|
||||
receiveJobTraceError({ commit() {} });
|
||||
|
||||
expect(flashSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiveJobTraceSuccess', () => {
|
||||
it('commits data', done => {
|
||||
testAction(
|
||||
receiveJobTraceSuccess,
|
||||
'data',
|
||||
mockedState,
|
||||
[{ type: types.RECEIVE_JOB_TRACE_SUCCESS, payload: 'data' }],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchJobTrace', () => {
|
||||
beforeEach(() => {
|
||||
mockedState.detailJob = {
|
||||
path: `${gl.TEST_HOST}/project/builds`,
|
||||
};
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(axios, 'get').and.callThrough();
|
||||
mock.onGet(`${gl.TEST_HOST}/project/builds/trace`).replyOnce(200, { html: 'html' });
|
||||
});
|
||||
|
||||
it('dispatches request', done => {
|
||||
testAction(
|
||||
fetchJobTrace,
|
||||
null,
|
||||
mockedState,
|
||||
[],
|
||||
[
|
||||
{ type: 'requestJobTrace' },
|
||||
{ type: 'receiveJobTraceSuccess', payload: { html: 'html' } },
|
||||
],
|
||||
done,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends get request to correct URL', () => {
|
||||
fetchJobTrace({ state: mockedState, dispatch() {} });
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${gl.TEST_HOST}/project/builds/trace`, {
|
||||
params: { format: 'json' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error', () => {
|
||||
beforeEach(() => {
|
||||
mock.onGet(`${gl.TEST_HOST}/project/builds/trace`).replyOnce(500);
|
||||
});
|
||||
|
||||
it('dispatches error', done => {
|
||||
testAction(
|
||||
fetchJobTrace,
|
||||
null,
|
||||
mockedState,
|
||||
[],
|
||||
[{ type: 'requestJobTrace' }, { type: 'receiveJobTraceError' }],
|
||||
done,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -147,6 +147,10 @@ describe('IDE pipelines mutations', () => {
|
|||
name: job.name,
|
||||
status: job.status,
|
||||
path: job.build_path,
|
||||
rawPath: `${job.build_path}/raw`,
|
||||
started: job.started,
|
||||
isLoading: false,
|
||||
output: '',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
@ -171,4 +175,49 @@ describe('IDE pipelines mutations', () => {
|
|||
expect(mockedState.stages[0].isCollapsed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(types.SET_DETAIL_JOB, () => {
|
||||
it('sets detail job', () => {
|
||||
mutations[types.SET_DETAIL_JOB](mockedState, jobs[0]);
|
||||
|
||||
expect(mockedState.detailJob).toEqual(jobs[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(types.REQUEST_JOB_TRACE, () => {
|
||||
beforeEach(() => {
|
||||
mockedState.detailJob = { ...jobs[0] };
|
||||
});
|
||||
|
||||
it('sets loading on detail job', () => {
|
||||
mutations[types.REQUEST_JOB_TRACE](mockedState);
|
||||
|
||||
expect(mockedState.detailJob.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe(types.RECEIVE_JOB_TRACE_ERROR, () => {
|
||||
beforeEach(() => {
|
||||
mockedState.detailJob = { ...jobs[0], isLoading: true };
|
||||
});
|
||||
|
||||
it('sets loading to false on detail job', () => {
|
||||
mutations[types.RECEIVE_JOB_TRACE_ERROR](mockedState);
|
||||
|
||||
expect(mockedState.detailJob.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(types.RECEIVE_JOB_TRACE_SUCCESS, () => {
|
||||
beforeEach(() => {
|
||||
mockedState.detailJob = { ...jobs[0], isLoading: true };
|
||||
});
|
||||
|
||||
it('sets output on detail job', () => {
|
||||
mutations[types.RECEIVE_JOB_TRACE_SUCCESS](mockedState, { html: 'html' });
|
||||
|
||||
expect(mockedState.detailJob.output).toBe('html');
|
||||
expect(mockedState.detailJob.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue