Commit 194c40df authored by Phil Hughes's avatar Phil Hughes

Merge branch '31397-job-detail-real-time' into 'master'

Improve Job detail view to make it refreshed in real-time instead of reloading

Closes #31397, #30901, #29948, and #24339

See merge request !11848
parents d25f6fcf 452202e3
...@@ -149,27 +149,34 @@ window.Build = (function () { ...@@ -149,27 +149,34 @@ window.Build = (function () {
Build.prototype.verifyTopPosition = function () { Build.prototype.verifyTopPosition = function () {
const $buildPage = $('.build-page'); const $buildPage = $('.build-page');
const $flashError = $('.alert-wrapper');
const $header = $('.build-header', $buildPage); const $header = $('.build-header', $buildPage);
const $runnersStuck = $('.js-build-stuck', $buildPage); const $runnersStuck = $('.js-build-stuck', $buildPage);
const $startsEnvironment = $('.js-environment-container', $buildPage); const $startsEnvironment = $('.js-environment-container', $buildPage);
const $erased = $('.js-build-erased', $buildPage); const $erased = $('.js-build-erased', $buildPage);
const prependTopDefault = 20;
// header + navigation + margin
let topPostion = 168; let topPostion = 168;
if ($header) { if ($header.length) {
topPostion += $header.outerHeight(); topPostion += $header.outerHeight();
} }
if ($runnersStuck) { if ($runnersStuck.length) {
topPostion += $runnersStuck.outerHeight(); topPostion += $runnersStuck.outerHeight();
} }
if ($startsEnvironment) { if ($startsEnvironment.length) {
topPostion += $startsEnvironment.outerHeight(); topPostion += $startsEnvironment.outerHeight() + prependTopDefault;
} }
if ($erased) { if ($erased.length) {
topPostion += $erased.outerHeight() + 10; topPostion += $erased.outerHeight() + prependTopDefault;
}
if ($flashError.length) {
topPostion += $flashError.outerHeight();
} }
this.$buildTrace.css({ this.$buildTrace.css({
...@@ -245,6 +252,7 @@ window.Build = (function () { ...@@ -245,6 +252,7 @@ window.Build = (function () {
Build.prototype.toggleSidebar = function (shouldHide) { Build.prototype.toggleSidebar = function (shouldHide) {
const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
const $toggleButton = $('.js-sidebar-build-toggle-header');
this.$buildTrace this.$buildTrace
.toggleClass('sidebar-expanded', shouldShow) .toggleClass('sidebar-expanded', shouldShow)
...@@ -252,6 +260,16 @@ window.Build = (function () { ...@@ -252,6 +260,16 @@ window.Build = (function () {
this.$sidebar this.$sidebar
.toggleClass('right-sidebar-expanded', shouldShow) .toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide); .toggleClass('right-sidebar-collapsed', shouldHide);
$('.js-build-page')
.toggleClass('sidebar-expanded', shouldShow)
.toggleClass('sidebar-collapsed', shouldHide);
if (this.$sidebar.hasClass('right-sidebar-expanded')) {
$toggleButton.addClass('hidden');
} else {
$toggleButton.removeClass('hidden');
}
}; };
Build.prototype.sidebarOnResize = function () { Build.prototype.sidebarOnResize = function () {
...@@ -266,6 +284,7 @@ window.Build = (function () { ...@@ -266,6 +284,7 @@ window.Build = (function () {
Build.prototype.sidebarOnClick = function () { Build.prototype.sidebarOnClick = function () {
if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
this.verifyTopPosition();
}; };
Build.prototype.updateArtifactRemoveDate = function () { Build.prototype.updateArtifactRemoveDate = function () {
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
/* global UsernameValidator */ /* global UsernameValidator */
/* global ActiveTabMemoizer */ /* global ActiveTabMemoizer */
/* global ShortcutsNavigation */ /* global ShortcutsNavigation */
/* global Build */
/* global IssuableIndex */ /* global IssuableIndex */
/* global ShortcutsIssuable */ /* global ShortcutsIssuable */
/* global ZenMode */ /* global ZenMode */
...@@ -119,9 +118,6 @@ import initSettingsPanels from './settings_panels'; ...@@ -119,9 +118,6 @@ import initSettingsPanels from './settings_panels';
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new UsersSelect(); new UsersSelect();
break; break;
case 'projects:jobs:show':
new Build();
break;
case 'projects:merge_requests:index': case 'projects:merge_requests:index':
case 'projects:issues:index': case 'projects:issues:index':
if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) { if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) {
......
<script>
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
name: 'jobHeaderSection',
props: {
job: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
components: {
ciHeader,
loadingIcon,
},
data() {
return {
actions: this.getActions(),
};
},
computed: {
status() {
return this.job && this.job.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length;
},
},
methods: {
getActions() {
const actions = [];
if (this.job.new_issue_path) {
actions.push({
label: 'New issue',
path: this.job.new_issue_path,
cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
type: 'ujs-link',
});
}
if (this.job.retry_path) {
actions.push({
label: 'Retry',
path: this.job.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block',
type: 'ujs-link',
});
}
return actions;
},
},
watch: {
job() {
this.actions = this.getActions();
},
},
};
</script>
<template>
<div class="js-build-header build-header top-area">
<ci-header
v-if="shouldRenderContent"
:status="status"
item-name="Job"
:item-id="job.id"
:time="job.created_at"
:user="job.user"
:actions="actions"
:hasSidebarButton="true"
/>
<loading-icon
v-if="isLoading"
size="2"
/>
</div>
</template>
<script>
export default {
name: 'SidebarDetailRow',
props: {
title: {
type: String,
required: false,
default: '',
},
value: {
type: String,
required: true,
},
},
computed: {
hasTitle() {
return this.title.length > 0;
},
},
};
</script>
<template>
<p class="build-detail-row">
<span
v-if="hasTitle"
class="build-light-text">
{{title}}:
</span>
{{value}}
</p>
</template>
<script>
import detailRow from './sidebar_detail_row.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
export default {
name: 'SidebarDetailsBlock',
props: {
job: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
mixins: [
timeagoMixin,
],
components: {
detailRow,
loadingIcon,
},
computed: {
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length > 0;
},
coverage() {
return `${this.job.coverage}%`;
},
duration() {
return timeIntervalInWords(this.job.duration);
},
queued() {
return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `#${this.job.runner.id}`;
},
},
};
</script>
<template>
<div>
<template v-if="shouldRenderContent">
<div
class="block retry-link"
v-if="job.retry_path || job.new_issue_path">
<a
v-if="job.new_issue_path"
class="js-new-issue btn btn-new btn-inverted"
:href="job.new_issue_path">
New issue
</a>
<a
v-if="job.retry_path"
class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path"
data-method="post"
rel="nofollow">
Retry
</a>
</div>
<div class="block">
<p
class="build-detail-row js-job-mr"
v-if="job.merge_request">
<span
class="build-light-text">
Merge Request:
</span>
<a :href="job.merge_request.path">
!{{job.merge_request.iid}}
</a>
</p>
<detail-row
class="js-job-duration"
v-if="job.duration"
title="Duration"
:value="duration"
/>
<detail-row
class="js-job-finished"
v-if="job.finished_at"
title="Finished"
:value="timeFormated(job.finished_at)"
/>
<detail-row
class="js-job-erased"
v-if="job.erased_at"
title="Erased"
:value="timeFormated(job.erased_at)"
/>
<detail-row
class="js-job-queued"
v-if="job.queued"
title="Queued"
:value="queued"
/>
<detail-row
class="js-job-runner"
v-if="job.runner"
title="Runner"
:value="runnerId"
/>
<detail-row
class="js-job-coverage"
v-if="job.coverage"
title="Coverage"
:value="coverage"
/>
<p
class="build-detail-row js-job-tags"
v-if="job.tags.length">
<span
class="build-light-text">
Tags:
</span>
<span
v-for="tag in job.tags"
key="tag"
class="label label-primary">
{{tag}}
</span>
</p>
<div
v-if="job.cancel_path"
class="btn-group prepend-top-5"
role="group">
<a
class="js-cancel-job btn btn-sm btn-default"
:href="job.cancel_path"
data-method="post"
rel="nofollow">
Cancel
</a>
</div>
</div>
</template>
<loading-icon
class="prepend-top-10"
v-if="isLoading"
size="2"
/>
</div>
</template>
/* global Flash */
import Vue from 'vue';
import JobMediator from './job_details_mediator';
import jobHeader from './components/header.vue';
import detailsBlock from './components/sidebar_details_block.vue';
document.addEventListener('DOMContentLoaded', () => {
const dataset = document.getElementById('js-job-details-vue').dataset;
const mediator = new JobMediator({ endpoint: dataset.endpoint });
mediator.fetchJob();
// Header
// eslint-disable-next-line no-new
new Vue({
el: '#js-build-header-vue',
data() {
return {
mediator,
};
},
components: {
jobHeader,
},
mounted() {
this.mediator.initBuildClass();
},
updated() {
// Wait for flash message to be appended
Vue.nextTick(() => {
if (this.mediator.build) {
this.mediator.build.verifyTopPosition();
}
});
},
render(createElement) {
return createElement('job-header', {
props: {
isLoading: this.mediator.state.isLoading,
job: this.mediator.store.state.job,
},
});
},
});
// Sidebar information block
// eslint-disable-next-line
new Vue({
el: '#js-details-block-vue',
data() {
return {
mediator,
};
},
components: {
detailsBlock,
},
render(createElement) {
return createElement('details-block', {
props: {
isLoading: this.mediator.state.isLoading,
job: this.mediator.store.state.job,
},
});
},
});
});
/* global Flash */
/* global Build */
import Visibility from 'visibilityjs';
import Poll from '../lib/utils/poll';
import JobStore from './stores/job_store';
import JobService from './services/job_service';
import '../build';
export default class JobMediator {
constructor(options = {}) {
this.options = options;
this.store = new JobStore();
this.service = new JobService(options.endpoint);
this.state = {
isLoading: false,
};
}
initBuildClass() {
this.build = new Build();
}
fetchJob() {
this.poll = new Poll({
resource: this.service,
method: 'getJob',
successCallback: this.successCallback.bind(this),
errorCallback: this.errorCallback.bind(this),
});
if (!Visibility.hidden()) {
this.state.isLoading = true;
this.poll.makeRequest();
} else {
this.getJob();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
}
getJob() {
return this.service.getJob()
.then(response => this.successCallback(response))
.catch(() => this.errorCallback());
}
successCallback(response) {
const data = response.json();
this.state.isLoading = false;
this.store.storeJob(data);
}
errorCallback() {
this.state.isLoading = false;
return new Flash('An error occurred while fetching the job.');
}
}
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class JobService {
constructor(endpoint) {
this.job = Vue.resource(endpoint);
}
getJob() {
return this.job.get();
}
}
export default class JobStore {
constructor() {
this.state = {
job: {},
};
}
storeJob(job = {}) {
this.state.job = job;
}
}
...@@ -146,3 +146,24 @@ window.dateFormat = dateFormat; ...@@ -146,3 +146,24 @@ window.dateFormat = dateFormat;
}; };
})(window); })(window);
}).call(window); }).call(window);
/**
* Port of ruby helper time_interval_in_words.
*
* @param {Number} seconds
* @return {String}
*/
// eslint-disable-next-line import/prefer-default-export
export function timeIntervalInWords(intervalInSeconds) {
const secondsInteger = parseInt(intervalInSeconds, 10);
const minutes = Math.floor(secondsInteger / 60);
const seconds = secondsInteger - (minutes * 60);
let text = '';
if (minutes >= 1) {
text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`;
} else {
text = `${seconds} ${gl.text.pluralize('second', seconds)}`;
}
return text;
}
...@@ -91,7 +91,7 @@ export default { ...@@ -91,7 +91,7 @@ export default {
@actionClicked="postAction" @actionClicked="postAction"
/> />
<loading-icon <loading-icon
v-else v-if="isLoading"
size="2"/> size="2"/>
</div> </div>
</template> </template>
...@@ -40,6 +40,11 @@ export default { ...@@ -40,6 +40,11 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
hasSidebarButton: {
type: Boolean,
required: false,
default: false,
},
}, },
mixins: [ mixins: [
...@@ -66,8 +71,9 @@ export default { ...@@ -66,8 +71,9 @@ export default {
}, },
}; };
</script> </script>
<template> <template>
<header class="page-content-header"> <header class="page-content-header ci-header-container">
<section class="header-main-content"> <section class="header-main-content">
<ci-icon-badge :status="status" /> <ci-icon-badge :status="status" />
...@@ -102,7 +108,7 @@ export default { ...@@ -102,7 +108,7 @@ export default {
</section> </section>
<section <section
class="header-action-button nav-controls" class="header-action-buttons"
v-if="actions.length"> v-if="actions.length">
<template <template
v-for="action in actions"> v-for="action in actions">
...@@ -113,6 +119,15 @@ export default { ...@@ -113,6 +119,15 @@ export default {
{{action.label}} {{action.label}}
</a> </a>
<a
v-if="action.type === 'ujs-link'"
:href="action.path"
data-method="post"
rel="nofollow"
:class="action.cssClass">
{{action.label}}
</a>
<button <button
v-else="action.type === 'button'" v-else="action.type === 'button'"
@click="onClickAction(action)" @click="onClickAction(action)"
...@@ -120,7 +135,6 @@ export default { ...@@ -120,7 +135,6 @@ export default {
:class="action.cssClass" :class="action.cssClass"
type="button"> type="button">
{{action.label}} {{action.label}}
<i <i
v-show="action.isLoading" v-show="action.isLoading"
class="fa fa-spin fa-spinner" class="fa fa-spin fa-spinner"
...@@ -128,6 +142,18 @@ export default { ...@@ -128,6 +142,18 @@ export default {
</i> </i>
</button> </button>
</template> </template>
<button
v-if="hasSidebarButton"
type="button"
class="btn btn-default visible-xs-block visible-sm-block sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header"
aria-label="Toggle Sidebar"
id="toggleSidebar">
<i
class="fa fa-angle-double-left"
aria-hidden="true"
aria-labelledby="toggleSidebar">
</i>
</button>
</section> </section>
</header> </header>
</template> </template>
...@@ -153,15 +153,16 @@ ...@@ -153,15 +153,16 @@
} }
.environment-information { .environment-information {
background-color: $gray-light;
border: 1px solid $border-color; border: 1px solid $border-color;
padding: 12px $gl-padding; padding: 8px $gl-padding 12px;
border-radius: $border-radius-default; border-radius: $border-radius-default;
svg { svg {
position: relative; position: relative;
top: 1px; top: 5px;
margin-right: 5px; margin-right: 5px;
width: 22px;
height: 22px;
} }
} }
...@@ -175,54 +176,31 @@ ...@@ -175,54 +176,31 @@
} }
} }
.status-message { .build-header {
display: inline-block; .ci-header-container,
color: $white-light; .header-action-buttons {
display: flex;
.status-icon {
display: inline-block;
width: 16px;
height: 33px;
} }
.status-text { .ci-header-container {
float: left; min-height: 54px;
opacity: 0;
margin-right: 10px;
font-weight: normal;
line-height: 1.8;
transition: opacity 1s ease-out;
&.animate {
animation: fade-out-status 2s ease;
}
} }
&:hover .status-text { .page-content-header {
opacity: 1; padding: 10px 0 9px;
} }
}
.build-header {
position: relative;
padding: 0;
display: flex;
min-height: 58px;
align-items: center;
@media (max-width: $screen-sm-max) {
padding-right: 40px;
margin-top: 6px;
.btn-inverted { .header-action-buttons {
display: none; @media (max-width: $screen-xs-max) {
.sidebar-toggle-btn {
margin-top: 0;
margin-left: 10px;
max-height: 34px;
}
} }
} }
.header-content { .header-content {
flex: 1;
line-height: 1.8;
a { a {
color: $gl-text-color; color: $gl-text-color;
...@@ -245,7 +223,7 @@ ...@@ -245,7 +223,7 @@
} }
.right-sidebar.build-sidebar { .right-sidebar.build-sidebar {
padding: $gl-padding 0; padding: 0;
&.right-sidebar-collapsed { &.right-sidebar-collapsed {
display: none; display: none;
...@@ -258,6 +236,10 @@ ...@@ -258,6 +236,10 @@
.block { .block {
width: 100%; width: 100%;
&:last-child {
border-bottom: 1px solid $border-gray-normal;
}
&.coverage { &.coverage {
padding: 0 16px 11px; padding: 0 16px 11px;
} }
...@@ -267,34 +249,39 @@ ...@@ -267,34 +249,39 @@
} }
} }
.js-build-variable { .trigger-build-variable {
color: $code-color; color: $code-color;
} }
.js-build-value { .trigger-build-value {
padding: 2px 4px; padding: 2px 4px;
color: $black; color: $black;
background-color: $white-light; background-color: $white-light;
} }
.build-sidebar-header { .label {
padding: 0 $gl-padding $gl-padding; margin-left: 2px;
.gutter-toggle {
margin-top: 0;
}
} }
.retry-link { .retry-link {
color: $gl-link-color;
display: none; display: none;
&:hover { .btn-inverted-secondary {
text-decoration: underline; color: $blue-500;
&:hover {
color: $white-light;
}
} }
@media (max-width: $screen-sm-max) { @media (max-width: $screen-sm-max) {
display: block; display: block;
.btn {
i {
margin-left: 5px;
}
}
} }
} }
...@@ -318,6 +305,12 @@ ...@@ -318,6 +305,12 @@
left: $gl-padding; left: $gl-padding;
width: auto; width: auto;
} }
svg {
position: relative;
top: 2px;
margin-right: 3px;
}
} }
.builds-container { .builds-container {
...@@ -379,6 +372,10 @@ ...@@ -379,6 +372,10 @@
} }
} }
} }
.link-commit {
color: $blue-600;
}
} }
.build-sidebar { .build-sidebar {
......
...@@ -986,10 +986,17 @@ ...@@ -986,10 +986,17 @@
} }
} }
.pipeline-header-container { .ci-header-container {
min-height: 55px; min-height: 55px;
.text-center { .text-center {
padding-top: 12px; padding-top: 12px;
} }
.header-action-buttons {
.btn,
a {
margin-left: 10px;
}
}
} }
...@@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base ...@@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base
false false
end end
# To be overriden when inherrited from
def cancelable?
false
end
def stuck? def stuck?
false false
end end
......
...@@ -34,10 +34,8 @@ class BuildDetailsEntity < BuildEntity ...@@ -34,10 +34,8 @@ class BuildDetailsEntity < BuildEntity
private private
def build_failed_issue_options def build_failed_issue_options
{ { title: "Build Failed ##{build.id}",
title: "Build Failed ##{build.id}", description: namespace_project_job_path(project.namespace, project, build) }
description: namespace_project_job_url(project.namespace, project, build)
}
end end
def current_user def current_user
......
...@@ -8,10 +8,14 @@ class BuildEntity < Grape::Entity ...@@ -8,10 +8,14 @@ class BuildEntity < Grape::Entity
path_to(:namespace_project_job, build) path_to(:namespace_project_job, build)
end end
expose :retry_path, if: -> (*) { build&.retryable? } do |build| expose :retry_path, if: -> (*) { retryable? } do |build|
path_to(:retry_namespace_project_job, build) path_to(:retry_namespace_project_job, build)
end end
expose :cancel_path, if: -> (*) { cancelable? } do |build|
path_to(:cancel_namespace_project_job, build)
end
expose :play_path, if: -> (*) { playable? } do |build| expose :play_path, if: -> (*) { playable? } do |build|
path_to(:play_namespace_project_job, build) path_to(:play_namespace_project_job, build)
end end
...@@ -25,6 +29,14 @@ class BuildEntity < Grape::Entity ...@@ -25,6 +29,14 @@ class BuildEntity < Grape::Entity
alias_method :build, :object alias_method :build, :object
def cancelable?
build.cancelable? && can?(request.current_user, :update_build, build)
end
def retryable?
build.retryable? && can?(request.current_user, :update_build, build)
end
def playable? def playable?
build.playable? && can?(request.current_user, :update_build, build) build.playable? && can?(request.current_user, :update_build, build)
end end
......
- builds = @build.pipeline.builds.to_a - builds = @build.pipeline.builds.to_a
%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
.block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default
Job
%strong ##{@build.id}
%a.gutter-toggle.pull-right.js-sidebar-build-toggle{ href: "#" }
= icon('angle-double-right')
- if @build.coverage
.block.coverage
.title
Test coverage
%p.build-detail-row
#{@build.coverage}%
.blocks-container .blocks-container
.block
%strong
= @build.name
%a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' }
= icon('angle-double-right')
#js-details-block-vue
- if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
.block{ class: ("block-first" if !@build.coverage) } .block{ class: ("block-first" if !@build.coverage) }
.title .title
...@@ -40,37 +36,6 @@ ...@@ -40,37 +36,6 @@
= link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do = link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
Browse Browse
.block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
.title
Job details
- if can?(current_user, :update_build, @build) && @build.retryable?
= link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post
- if @build.merge_request
%p.build-detail-row
%span.build-light-text Merge Request:
= link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold'
- if @build.duration
%p.build-detail-row
%span.build-light-text Duration:
= time_interval_in_words(@build.duration)
- if @build.finished_at
%p.build-detail-row
%span.build-light-text Finished:
#{time_ago_with_tooltip(@build.finished_at)}
- if @build.erased_at
%p.build-detail-row
%span.build-light-text Erased:
#{time_ago_with_tooltip(@build.erased_at)}
%p.build-detail-row
%span.build-light-text Runner:
- if @build.runner && current_user && current_user.admin
= link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id)
- elsif @build.runner
\##{@build.runner.id}
.btn-group.btn-group-justified{ role: :group }
- if @build.active?
= link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
- if @build.trigger_request - if @build.trigger_request
.build-widget .build-widget
%h4.title %h4.title
...@@ -87,26 +52,29 @@ ...@@ -87,26 +52,29 @@
- @build.trigger_request.variables.each do |key, value| - @build.trigger_request.variables.each do |key, value|
.hide.js-build .hide.js-build
.js-build-variable= key .js-build-variable.trigger-build-variable= key
.js-build-value= value .js-build-value.trigger-build-value= value
.block .block
.title %p
Commit title Commit
= link_to @build.pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha), class: 'commit-sha link-commit'
= clipboard_button(text: @build.pipeline.short_sha, title: "Copy commit SHA to clipboard")
- if @build.merge_request
in
= link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'link-commit'
%p.build-light-text.append-bottom-0 %p.build-light-text.append-bottom-0
#{@build.pipeline.git_commit_title} #{@build.pipeline.git_commit_title}
- if @build.tags.any?
.block
.title
Tags
- @build.tag_list.each do |tag|
%span.label.label-primary
= tag
- if @build.pipeline.stages_count > 1 - if @build.pipeline.stages_count > 1
.dropdown.build-dropdown .dropdown.build-dropdown
.title Stage .title
%span{ class: "ci-status-icon-#{@build.pipeline.status}" }
= ci_icon_for_status(@build.pipeline.status)
= link_to "##{@build.pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @build.pipeline), class: 'link-commit'
from
= link_to "#{@build.pipeline.ref}", namespace_project_branch_path(@project.namespace, @project, @build.pipeline.ref), class: 'link-commit'
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.stage-selection More %span.stage-selection More
= icon('chevron-down') = icon('chevron-down')
......
...@@ -3,9 +3,8 @@ ...@@ -3,9 +3,8 @@
= render "projects/pipelines/head" = render "projects/pipelines/head"
%div{ class: container_class } %div{ class: container_class }
.build-page .build-page.js-build-page
= render "header" #js-build-header-vue
- if @build.stuck? - if @build.stuck?
- unless @build.any_runners_online? - unless @build.any_runners_online?
.bs-callout.bs-callout-warning.js-build-stuck .bs-callout.bs-callout-warning.js-build-stuck
...@@ -47,47 +46,52 @@ ...@@ -47,47 +46,52 @@
- if environment.try(:last_deployment) - if environment.try(:last_deployment)
and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')} and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')}
.prepend-top-default.js-build-erased - if @build.erased?
- if @build.erased? .prepend-top-default.js-build-erased
.erased.alert.alert-warning .erased.alert.alert-warning
- if @build.erased_by_user? - if @build.erased_by_user?
Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)} Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)}
- else - else
Job has been erased #{time_ago_with_tooltip(@build.erased_at)} Job has been erased #{time_ago_with_tooltip(@build.erased_at)}
.prepend-top-default .build-trace-container#build-trace
.build-trace-container#build-trace .top-bar.sticky
.top-bar.sticky .js-truncated-info.truncated-info.hidden<
.js-truncated-info.truncated-info.hidden< Showing last
Showing last %span.js-truncated-info-size.truncated-info-size><
%span.js-truncated-info-size.truncated-info-size>< KiB of log -
KiB of log - %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw
%a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw .controllers
.controllers - if @build.has_trace?
- if @build.has_trace? = link_to raw_namespace_project_job_path(@project.namespace, @project, @build),
= link_to raw_namespace_project_job_path(@project.namespace, @project, @build), title: 'Show complete raw',
title: 'Show complete raw', data: { placement: 'top', container: 'body' },
data: { placement: 'top', container: 'body' }, class: 'js-raw-link-controller has-tooltip controllers-buttons' do
class: 'js-raw-link-controller has-tooltip controllers-buttons' do = icon('file-text-o')
= icon('file-text-o')
- if can?(current_user, :update_build, @project) && @build.erasable? - if can?(current_user, :update_build, @project) && @build.erasable?
= link_to erase_namespace_project_job_path(@project.namespace, @project, @build), = link_to erase_namespace_project_job_path(@project.namespace, @project, @build),
method: :post, method: :post,
data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' }, data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' },
title: 'Erase job log', title: 'Erase job log',
class: 'has-tooltip js-erase-link controllers-buttons' do class: 'has-tooltip js-erase-link controllers-buttons' do
= icon('trash') = icon('trash')
.has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} } .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} }
%button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
= custom_icon('scroll_up') = custom_icon('scroll_up')
.has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} } .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} }
%button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
= custom_icon('scroll_down') = custom_icon('scroll_down')
.bash.sticky.js-scroll-container .bash.sticky.js-scroll-container
%code.js-build-output %code.js-build-output
.build-loader-animation.js-build-refresh .build-loader-animation.js-build-refresh
= render "sidebar" = render "sidebar"
.js-build-options{ data: javascript_build_options } .js-build-options{ data: javascript_build_options }
#js-job-details-vue{ data: { endpoint: namespace_project_job_path(@project.namespace, @project, @build, format: :json) } }
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('job_details')
---
title: Adds realtime feature to job show view header and sidebar info. Updates UX.
merge_request:
author:
...@@ -44,6 +44,7 @@ var config = { ...@@ -44,6 +44,7 @@ var config = {
groups_list: './groups_list.js', groups_list: './groups_list.js',
issue_show: './issue_show/index.js', issue_show: './issue_show/index.js',
integrations: './integrations', integrations: './integrations',
job_details: './jobs/job_details_bundle.js',
locale: './locale/index.js', locale: './locale/index.js',
main: './main.js', main: './main.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
...@@ -158,6 +159,7 @@ var config = { ...@@ -158,6 +159,7 @@ var config = {
'filtered_search', 'filtered_search',
'groups', 'groups',
'issue_show', 'issue_show',
'job_details',
'merge_conflicts', 'merge_conflicts',
'notebook_viewer', 'notebook_viewer',
'pdf_viewer', 'pdf_viewer',
......
...@@ -27,6 +27,7 @@ Feature: Project Builds Permissions ...@@ -27,6 +27,7 @@ Feature: Project Builds Permissions
When I visit project builds page When I visit project builds page
Then page status code should be 404 Then page status code should be 404
@javascript
Scenario: I try to visit build details of internal project with access to builds Scenario: I try to visit build details of internal project with access to builds
Given The project is internal Given The project is internal
And public access for builds is enabled And public access for builds is enabled
......
...@@ -6,16 +6,19 @@ Feature: Project Builds Summary ...@@ -6,16 +6,19 @@ Feature: Project Builds Summary
And project has coverage enabled And project has coverage enabled
And project has a recent build And project has a recent build
@javascript
Scenario: I browse build details page Scenario: I browse build details page
When I visit recent build details page When I visit recent build details page
Then I see details of a build Then I see details of a build
And I see build trace And I see build trace
@javascript
Scenario: I browse project builds page Scenario: I browse project builds page
When I visit project builds page When I visit project builds page
Then I see coverage Then I see coverage
Then I see button to CI Lint Then I see button to CI Lint
@javascript
Scenario: I erase a build Scenario: I erase a build
Given recent build is successful Given recent build is successful
And recent build has a build trace And recent build has a build trace
......
...@@ -13,7 +13,7 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps ...@@ -13,7 +13,7 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps
step 'I see button to CI Lint' do step 'I see button to CI Lint' do
page.within('.nav-controls') do page.within('.nav-controls') do
ci_lint_tool_link = page.find_link('CI lint') ci_lint_tool_link = page.find_link('CI lint')
expect(ci_lint_tool_link[:href]).to eq ci_lint_path expect(ci_lint_tool_link[:href]).to end_with(ci_lint_path)
end end
end end
......
...@@ -5,6 +5,7 @@ feature 'Jobs', :feature do ...@@ -5,6 +5,7 @@ feature 'Jobs', :feature do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:user_access_level) { :developer } let(:user_access_level) { :developer }
let(:project) { create(:project) } let(:project) { create(:project) }
let(:namespace) { project.namespace }
let(:pipeline) { create(:ci_pipeline, project: project) } let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, :trace, pipeline: pipeline) } let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
...@@ -113,10 +114,16 @@ feature 'Jobs', :feature do ...@@ -113,10 +114,16 @@ feature 'Jobs', :feature do
describe "GET /:project/jobs/:id" do describe "GET /:project/jobs/:id" do
context "Job from project" do context "Job from project" do
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
before do before do
visit namespace_project_job_path(project.namespace, project, build) visit namespace_project_job_path(project.namespace, project, build)
end end
it 'shows status name', :js do
expect(page).to have_css('.ci-status.ci-success', text: 'passed')
end
it 'shows commit`s data' do it 'shows commit`s data' do
expect(page.status_code).to eq(200) expect(page.status_code).to eq(200)
expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.sha[0..7]
...@@ -129,6 +136,48 @@ feature 'Jobs', :feature do ...@@ -129,6 +136,48 @@ feature 'Jobs', :feature do
end end
end end
context 'when job is not running', :js do
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
before do
visit namespace_project_job_path(project.namespace, project, build)
end
it 'shows retry button' do
expect(page).to have_link('Retry')
end
context 'if build passed' do
it 'does not show New issue button' do
expect(page).not_to have_link('New issue')
end
end
context 'if build failed' do
let(:build) { create(:ci_build, :failed, pipeline: pipeline) }
before do
visit namespace_project_job_path(namespace, project, build)
end
it 'shows New issue button' do
expect(page).to have_link('New issue')
end
it 'links to issues/new with the title and description filled in' do
button_title = "Build Failed ##{build.id}"
build_path = namespace_project_job_path(namespace, project, build)
options = { issue: { title: button_title, description: build_path } }
href = new_namespace_project_issue_path(namespace, project, options)
page.within('.header-action-buttons') do
expect(find('.js-new-issue')['href']).to include(href)
end
end
end
end
context "Job from other project" do context "Job from other project" do
before do before do
visit namespace_project_job_path(project.namespace, project, build2) visit namespace_project_job_path(project.namespace, project, build2)
...@@ -305,63 +354,38 @@ feature 'Jobs', :feature do ...@@ -305,63 +354,38 @@ feature 'Jobs', :feature do
end end
end end
describe "POST /:project/jobs/:id/cancel" do describe "POST /:project/jobs/:id/cancel", :js do
context "Job from project" do context "Job from project" do
before do before do
build.run! build.run!
visit namespace_project_job_path(project.namespace, project, build) visit namespace_project_job_path(project.namespace, project, build)
click_link "Cancel" find('.js-cancel-job').click()
end end
it 'loads the page and shows all needed controls' do it 'loads the page and shows all needed controls' do
expect(page.status_code).to eq(200) expect(page.status_code).to eq(200)
expect(page).to have_content 'canceled'
expect(page).to have_content 'Retry' expect(page).to have_content 'Retry'
end end
end end
context "Job from other project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2))
end
it { expect(page.status_code).to eq(404) }
end
end end
describe "POST /:project/jobs/:id/retry" do describe "POST /:project/jobs/:id/retry" do
context "Job from project" do context "Job from project", :js do
before do before do
build.run! build.run!
visit namespace_project_job_path(project.namespace, project, build) visit namespace_project_job_path(project.namespace, project, build)
click_link 'Cancel' find('.js-cancel-job').click()
page.within('.build-header') do find('.js-retry-button').trigger('click')
click_link 'Retry job'
end
end end
it 'shows the right status and buttons' do it 'shows the right status and buttons', :js do
expect(page).to have_http_status(200) expect(page).to have_http_status(200)
expect(page).to have_content 'pending'
page.within('aside.right-sidebar') do page.within('aside.right-sidebar') do
expect(page).to have_content 'Cancel' expect(page).to have_content 'Cancel'
end end
end end
end end
context "Job from other project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link 'Cancel'
page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2))
end
it { expect(page).to have_http_status(404) }
end
context "Job that current user is not allowed to retry" do context "Job that current user is not allowed to retry" do
before do before do
build.run! build.run!
...@@ -435,20 +459,17 @@ feature 'Jobs', :feature do ...@@ -435,20 +459,17 @@ feature 'Jobs', :feature do
Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
build.run! build.run!
allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths)
.and_return(paths)
visit namespace_project_job_path(project.namespace, project, build)
end end
context 'when build has trace in file', :js do context 'when build has trace in file', :js do
let(:paths) do
[existing_file]
end
before do before do
find('.js-raw-link-controller').click() allow_any_instance_of(Gitlab::Ci::Trace)
.to receive(:paths)
.and_return([existing_file])
visit namespace_project_job_path(namespace, project, build)
find('.js-raw-link-controller').click
end end
it 'sends the right headers' do it 'sends the right headers' do
...@@ -458,11 +479,17 @@ feature 'Jobs', :feature do ...@@ -458,11 +479,17 @@ feature 'Jobs', :feature do
end end
end end
context 'when job has trace in DB' do context 'when job has trace in the database', :js do
let(:paths) { [] } before do
allow_any_instance_of(Gitlab::Ci::Trace)
.to receive(:paths)
.and_return([])
visit namespace_project_job_path(namespace, project, build)
end
it 'sends the right headers' do it 'sends the right headers' do
expect(page.status_code).not_to have_selector('.js-raw-link-controller') expect(page).not_to have_selector('.js-raw-link-controller')
end end
end end
end end
......
...@@ -132,23 +132,6 @@ describe('Build', () => { ...@@ -132,23 +132,6 @@ describe('Build', () => {
expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/); expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
expect($('#build-trace .js-build-output').text()).toMatch(/Different/); expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
}); });
it('reloads the page when the build is done', () => {
spyOn(gl.utils, 'visitUrl');
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
deferred.resolve({
html: '<span>Final</span>',
status: 'passed',
append: true,
complete: true,
});
this.build = new Build();
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL);
});
}); });
describe('truncated information', () => { describe('truncated information', () => {
......
import '~/lib/utils/datetime_utility'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
(() => { (() => {
describe('Date time utils', () => { describe('Date time utils', () => {
...@@ -82,4 +82,13 @@ import '~/lib/utils/datetime_utility'; ...@@ -82,4 +82,13 @@ import '~/lib/utils/datetime_utility';
}); });
}); });
}); });
describe('timeIntervalInWords', () => {
it('should return string with number of minutes and seconds', () => {
expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(timeIntervalInWords(1)).toEqual('1 second');
expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
});
});
})(); })();
import Vue from 'vue';
import headerComponent from '~/jobs/components/header.vue';
describe('Job details header', () => {
let HeaderComponent;
let vm;
let props;
beforeEach(() => {
HeaderComponent = Vue.extend(headerComponent);
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
props = {
job: {
status: {
group: 'failed',
icon: 'ci-status-failed',
label: 'failed',
text: 'failed',
details_path: 'path',
},
id: 123,
created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'path',
new_issue_path: 'path',
},
isLoading: false,
};
vm = new HeaderComponent({ propsData: props }).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render provided job information', () => {
expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
});
it('should render retry link', () => {
expect(
vm.$el.querySelector('.js-retry-button').getAttribute('href'),
).toEqual(props.job.retry_path);
});
it('should render new issue link', () => {
expect(
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
).toEqual(props.job.new_issue_path);
});
});
import Vue from 'vue';
import JobMediator from '~/jobs/job_details_mediator';
import job from './mock_data';
describe('JobMediator', () => {
let mediator;
beforeEach(() => {
mediator = new JobMediator({ endpoint: 'foo' });
});
it('should set defaults', () => {
expect(mediator.store).toBeDefined();
expect(mediator.service).toBeDefined();
expect(mediator.options).toEqual({ endpoint: 'foo' });
expect(mediator.state.isLoading).toEqual(false);
});
describe('request and store data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(job), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
});
it('should store received data', (done) => {
mediator.fetchJob();
setTimeout(() => {
expect(mediator.store.state.job).toEqual(job);
done();
}, 0);
});
});
});
import JobStore from '~/jobs/stores/job_store';
import job from './mock_data';
describe('Job Store', () => {
let store;
beforeEach(() => {
store = new JobStore();
});
it('should set defaults', () => {
expect(store.state.job).toEqual({});
});
describe('storeJob', () => {
it('should store empty object if none is provided', () => {
store.storeJob();
expect(store.state.job).toEqual({});
});
it('should store provided argument', () => {
store.storeJob(job);
expect(store.state.job).toEqual(job);
});
});
});
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
export default {
id: 4757,
name: 'test',
build_path: '/root/ci-mock/-/jobs/4757',
retry_path: '/root/ci-mock/-/jobs/4757/retry',
cancel_path: '/root/ci-mock/-/jobs/4757/cancel',
new_issue_path: '/root/ci-mock/issues/new',
playable: false,
created_at: threeWeeksAgo.toISOString(),
updated_at: threeWeeksAgo.toISOString(),
finished_at: threeWeeksAgo.toISOString(),
queued: 9.54,
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/-/jobs/4757',
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
action: {
icon: 'icon_action_retry',
title: 'Retry',
path: '/root/ci-mock/-/jobs/4757/retry',
method: 'post',
},
},
coverage: 20,
erased_at: threeWeeksAgo.toISOString(),
duration: 6.785563,
tags: ['tag'],
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
erase_path: '/root/ci-mock/-/jobs/4757/erase',
artifacts: [null],
runner: {
id: 1,
description: 'local ci runner',
edit_path: '/root/ci-mock/runners/1/edit',
},
pipeline: {
id: 140,
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
active: false,
coverage: null,
source: 'unknown',
created_at: '2017-05-24T09:59:58.634Z',
updated_at: '2017-06-01T17:32:00.062Z',
path: '/root/ci-mock/pipelines/140',
flags: {
latest: true,
stuck: false,
yaml_errors: false,
retryable: false,
cancelable: false,
},
details: {
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/pipelines/140',
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
},
duration: 6,
finished_at: '2017-06-01T17:32:00.042Z',
},
ref: {
name: 'abc',
path: '/root/ci-mock/commits/abc',
tag: false,
branch: true,
},
commit: {
id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
short_id: 'c5864777',
title: 'Add new file',
created_at: '2017-05-24T10:59:52.000+01:00',
parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'],
message: 'Add new file',
author_name: 'Root',
author_email: 'admin@example.com',
authored_date: '2017-05-24T10:59:52.000+01:00',
committer_name: 'Root',
committer_email: 'admin@example.com',
committed_date: '2017-05-24T10:59:52.000+01:00',
author: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
commit_url: 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
},
},
merge_request: {
iid: 2,
path: '/root/ci-mock/merge_requests/2',
},
raw_path: '/root/ci-mock/builds/4757/raw',
};
import Vue from 'vue';
import sidebarDetailRow from '~/jobs/components/sidebar_detail_row.vue';
describe('Sidebar detail row', () => {
let SidebarDetailRow;
let vm;
beforeEach(() => {
SidebarDetailRow = Vue.extend(sidebarDetailRow);
});
afterEach(() => {
vm.$destroy();
});
it('should render no title', () => {
vm = new SidebarDetailRow({
propsData: {
value: 'this is the value',
},
}).$mount();
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('this is the value');
});
beforeEach(() => {
vm = new SidebarDetailRow({
propsData: {
title: 'this is the title',
value: 'this is the value',
},
}).$mount();
});
it('should render provided title and value', () => {
expect(
vm.$el.textContent.replace(/\s+/g, ' ').trim(),
).toEqual('this is the title: this is the value');
});
});
import Vue from 'vue';
import sidebarDetailsBlock from '~/jobs/components/sidebar_details_block.vue';
import job from './mock_data';
describe('Sidebar details block', () => {
let SidebarComponent;
let vm;
function trimWhitespace(element) {
return element.textContent.replace(/\s+/g, ' ').trim();
}
beforeEach(() => {
SidebarComponent = Vue.extend(sidebarDetailsBlock);
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
it('should render a loading spinner', () => {
vm = new SidebarComponent({
propsData: {
job: {},
isLoading: true,
},
}).$mount();
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
});
});
beforeEach(() => {
vm = new SidebarComponent({
propsData: {
job,
isLoading: false,
},
}).$mount();
});
describe('actions', () => {
it('should render link to new issue', () => {
expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(job.new_issue_path);
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
});
it('should render link to retry job', () => {
expect(vm.$el.querySelector('.js-retry-job').getAttribute('href')).toEqual(job.retry_path);
});
it('should render link to cancel job', () => {
expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
});
});
describe('information', () => {
it('should render merge request link', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
).toEqual('Merge Request: !2');
expect(
vm.$el.querySelector('.js-job-mr a').getAttribute('href'),
).toEqual(job.merge_request.path);
});
it('should render job duration', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-duration')),
).toEqual('Duration: 6 seconds');
});
it('should render erased date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-erased')),
).toEqual('Erased: 3 weeks ago');
});
it('should render finished date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-finished')),
).toEqual('Finished: 3 weeks ago');
});
it('should render queued date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-queued')),
).toEqual('Queued: 9 seconds');
});
it('should render runner ID', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-runner')),
).toEqual('Runner: #1');
});
it('should render coverage', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
).toEqual('Coverage: 20%');
});
it('should render tags', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-tags')),
).toEqual('Tags: tag');
});
});
});
...@@ -43,6 +43,7 @@ describe('Header CI Component', () => { ...@@ -43,6 +43,7 @@ describe('Header CI Component', () => {
isLoading: false, isLoading: false,
}, },
], ],
hasSidebarButton: true,
}; };
vm = new HeaderCi({ vm = new HeaderCi({
...@@ -90,4 +91,8 @@ describe('Header CI Component', () => { ...@@ -90,4 +91,8 @@ describe('Header CI Component', () => {
done(); done();
}); });
}); });
it('should render sidebar toggle button', () => {
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).toBeDefined();
});
}); });
...@@ -2,12 +2,13 @@ require 'spec_helper' ...@@ -2,12 +2,13 @@ require 'spec_helper'
describe BuildEntity do describe BuildEntity do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:build) { create(:ci_build, :failed) } let(:build) { create(:ci_build) }
let(:project) { build.project } let(:project) { build.project }
let(:request) { double('request') } let(:request) { double('request') }
before do before do
allow(request).to receive(:current_user).and_return(user) allow(request).to receive(:current_user).and_return(user)
project.add_developer(user)
end end
let(:entity) do let(:entity) do
...@@ -16,9 +17,8 @@ describe BuildEntity do ...@@ -16,9 +17,8 @@ describe BuildEntity do
subject { entity.as_json } subject { entity.as_json }
it 'contains paths to build page and retry action' do it 'contains paths to build page action' do
expect(subject).to include(:build_path, :retry_path) expect(subject).to include(:build_path)
expect(subject[:retry_path]).not_to be_nil
end end
it 'does not contain sensitive information' do it 'does not contain sensitive information' do
...@@ -39,12 +39,32 @@ describe BuildEntity do ...@@ -39,12 +39,32 @@ describe BuildEntity do
expect(subject[:status]).to include :icon, :favicon, :text, :label expect(subject[:status]).to include :icon, :favicon, :text, :label
end end
context 'when build is a regular job' do context 'when build is retryable' do
before do
build.update(status: :failed)
end
it 'contains cancel path' do
expect(subject).to include(:retry_path)
end
end
context 'when build is cancelable' do
before do
build.update(status: :running)
end
it 'contains cancel path' do
expect(subject).to include(:cancel_path)
end
end
context 'when build is a regular build' do
it 'does not contain path to play action' do it 'does not contain path to play action' do
expect(subject).not_to include(:play_path) expect(subject).not_to include(:play_path)
end end
it 'is not a playable job' do it 'is not a playable build' do
expect(subject[:playable]).to be false expect(subject[:playable]).to be false
end end
end end
......
...@@ -15,36 +15,6 @@ describe 'projects/jobs/show', :view do ...@@ -15,36 +15,6 @@ describe 'projects/jobs/show', :view do
allow(view).to receive(:can?).and_return(true) allow(view).to receive(:can?).and_return(true)
end end
describe 'job information in header' do
let(:build) do
create(:ci_build, :success, environment: 'staging')
end
before do
render
end
it 'shows status name' do
expect(rendered).to have_css('.ci-status.ci-success', text: 'passed')
end
it 'does not render a link to the job' do
expect(rendered).not_to have_link('passed')
end
it 'shows job id' do
expect(rendered).to have_css('.js-build-id', text: build.id)
end
it 'shows a link to the pipeline' do
expect(rendered).to have_link(build.pipeline.id)
end
it 'shows a link to the commit' do
expect(rendered).to have_link(build.pipeline.short_sha)
end
end
describe 'environment info in job view' do describe 'environment info in job view' do
context 'job with latest deployment' do context 'job with latest deployment' do
let(:build) do let(:build) do
...@@ -215,34 +185,6 @@ describe 'projects/jobs/show', :view do ...@@ -215,34 +185,6 @@ describe 'projects/jobs/show', :view do
end end
end end
context 'when job is not running' do
before do
build.success!
render
end
it 'shows retry button' do
expect(rendered).to have_link('Retry')
end
context 'if build passed' do
it 'does not show New issue button' do
expect(rendered).not_to have_link('New issue')
end
end
context 'if build failed' do
before do
build.status = 'failed'
render
end
it 'shows New issue button' do
expect(rendered).to have_link('New issue')
end
end
end
describe 'commit title in sidebar' do describe 'commit title in sidebar' do
let(:commit_title) { project.commit.title } let(:commit_title) { project.commit.title }
...@@ -269,25 +211,4 @@ describe 'projects/jobs/show', :view do ...@@ -269,25 +211,4 @@ describe 'projects/jobs/show', :view do
expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2') expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2')
end end
end end
describe 'New issue button' do
before do
build.status = 'failed'
render
end
it 'links to issues/new with the title and description filled in' do
title = "Build Failed ##{build.id}"
build_url = namespace_project_job_url(project.namespace, project, build)
href = new_namespace_project_issue_path(
project.namespace,
project,
issue: {
title: title,
description: build_url
}
)
expect(rendered).to have_link('New issue', href: href)
end
end
end end
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