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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2018-06-06 12:07:37 +0300
committerFilipa Lacerda <filipa@gitlab.com>2018-06-06 12:07:37 +0300
commitbb6b73cf3c64a6f963e6afc657cab937db46564b (patch)
tree2d934aa9e94adf7952e18ca1ba46c05a3022669a
parent4cfe9209106d4697cd8836039e93e0c74708d811 (diff)
parentec37b1b20c09d3a5b5a0e2cd0b03311ec95d7c68 (diff)
Merge branch 'ide-jobs-log' into 'master'
Show job logs in web IDE Closes #46245 See merge request gitlab-org/gitlab-ce!19279
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue136
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue47
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue66
-rw-r--r--app/assets/javascripts/ide/components/jobs/item.vue40
-rw-r--r--app/assets/javascripts/ide/components/jobs/list.vue3
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue4
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue10
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js25
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js6
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutations.js13
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/utils.js4
-rw-r--r--app/assets/stylesheets/pages/builds.scss1
-rw-r--r--app/assets/stylesheets/pages/repo.scss27
-rw-r--r--spec/javascripts/ide/components/jobs/detail/description_spec.js28
-rw-r--r--spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js59
-rw-r--r--spec/javascripts/ide/components/jobs/detail_spec.js180
-rw-r--r--spec/javascripts/ide/components/jobs/item_spec.js10
-rw-r--r--spec/javascripts/ide/mock_data.js4
-rw-r--r--spec/javascripts/ide/stores/modules/pipelines/actions_spec.js135
-rw-r--r--spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js49
22 files changed, 823 insertions, 26 deletions
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
new file mode 100644
index 00000000000..4d234a36fe5
--- /dev/null
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue
new file mode 100644
index 00000000000..def6bac3157
--- /dev/null
+++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
new file mode 100644
index 00000000000..4e19e6e9c84
--- /dev/null
+++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue
index c33936021d4..c8e621504f0 100644
--- a/app/assets/javascripts/ide/components/jobs/item.vue
+++ b/app/assets/javascripts/ide/components/jobs/item.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/list.vue b/app/assets/javascripts/ide/components/jobs/list.vue
index bdd0364c9b9..3b16b860ecd 100644
--- a/app/assets/javascripts/ide/components/jobs/list.vue
+++ b/app/assets/javascripts/ide/components/jobs/list.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 5b24bb1f5a7..b1428f885fb 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -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>
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index 703c4a70cfa..aafd6a15a78 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -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)"
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 33cd20caf52..65886c02b92 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -23,4 +23,5 @@ export const viewerTypes = {
export const rightSidebarViews = {
pipelines: 'pipelines-list',
+ jobsDetail: 'jobs-detail',
};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 1ebe487263b..3de3e6d3376 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -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 () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js
index 3ddc8409c5b..f4c36b9d96f 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
index 745797e1ee5..5a2213bbe89 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
@@ -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;
+ },
};
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
index 0f83b315fff..8651e267b53 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
@@ -3,4 +3,5 @@ export default () => ({
isLoadingJobs: false,
latestPipeline: null,
stages: [],
+ detailJob: null,
});
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/utils.js b/app/assets/javascripts/ide/stores/modules/pipelines/utils.js
index 9f4b0d7d726..a6caca2d2dc 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/utils.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/utils.js
@@ -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,
});
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 9ee02ca1d83..9213ccd4cdf 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -75,6 +75,7 @@
.top-bar {
height: 35px;
+ min-height: 35px;
background: $gray-light;
border: 1px solid $border-color;
color: $gl-text-color;
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index 2b3cc33c8ae..ffa8d13b09c 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -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;
+}
diff --git a/spec/javascripts/ide/components/jobs/detail/description_spec.js b/spec/javascripts/ide/components/jobs/detail/description_spec.js
new file mode 100644
index 00000000000..9b715a41499
--- /dev/null
+++ b/spec/javascripts/ide/components/jobs/detail/description_spec.js
@@ -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);
+ });
+});
diff --git a/spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js b/spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js
new file mode 100644
index 00000000000..fff382a107f
--- /dev/null
+++ b/spec/javascripts/ide/components/jobs/detail/scroll_button_spec.js
@@ -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();
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/jobs/detail_spec.js b/spec/javascripts/ide/components/jobs/detail_spec.js
new file mode 100644
index 00000000000..641ba06f653
--- /dev/null
+++ b/spec/javascripts/ide/components/jobs/detail_spec.js
@@ -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();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/jobs/item_spec.js b/spec/javascripts/ide/components/jobs/item_spec.js
index 7c1dd4e475c..79e07f00e7b 100644
--- a/spec/javascripts/ide/components/jobs/item_spec.js
+++ b/spec/javascripts/ide/components/jobs/item_spec.js
@@ -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();
+ });
+ });
});
diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js
index dcf857f7e04..dd87a43f370 100644
--- a/spec/javascripts/ide/mock_data.js
+++ b/spec/javascripts/ide/mock_data.js
@@ -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(),
},
];
diff --git a/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js b/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js
index f26eaf9c81f..f2f8e780cd1 100644
--- a/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js
+++ b/spec/javascripts/ide/stores/modules/pipelines/actions_spec.js
@@ -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,
+ );
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js b/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js
index 6285c01d483..eb7346bd5fc 100644
--- a/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js
+++ b/spec/javascripts/ide/stores/modules/pipelines/mutations_spec.js
@@ -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);
+ });
+ });
});