Commit 93780da6 authored by Mayra Cabrera's avatar Mayra Cabrera Committed by Grzegorz Bizon

Resolve "Show `failure_reason` in jobs view content section"

parent 6ef8b497
<script> <script>
import ciHeader from '../../vue_shared/components/header_ci_component.vue'; import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import callout from '../../vue_shared/components/callout.vue';
export default { export default {
name: 'JobHeaderSection', name: 'JobHeaderSection',
components: { components: {
ciHeader, ciHeader,
loadingIcon, loadingIcon,
callout,
},
props: {
job: {
type: Object,
required: true,
}, },
props: { isLoading: {
job: { type: Boolean,
type: Object, required: true,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
}, },
data() { },
return { data() {
actions: this.getActions(), return {
}; actions: this.getActions(),
};
},
computed: {
status() {
return this.job && this.job.status;
}, },
computed: { shouldRenderContent() {
status() { return !this.isLoading && Object.keys(this.job).length;
return this.job && this.job.status;
},
shouldRenderContent() {
return !this.isLoading && Object.keys(this.job).length;
},
/**
* When job has not started the key will be `false`
* When job started the key will be a string with a date.
*/
jobStarted() {
return !this.job.started === false;
},
}, },
watch: { shouldRenderReason() {
job() { return !!(this.job.status && this.job.callout_message);
this.actions = this.getActions();
},
}, },
methods: { /**
getActions() { * When job has not started the key will be `false`
const actions = []; * When job started the key will be a string with a date.
*/
jobStarted() {
return !this.job.started === false;
},
},
watch: {
job() {
this.actions = this.getActions();
},
},
methods: {
getActions() {
const actions = [];
if (this.job.new_issue_path) { if (this.job.new_issue_path) {
actions.push({ actions.push({
label: 'New issue', label: 'New issue',
path: this.job.new_issue_path, path: this.job.new_issue_path,
cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block', cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block',
type: 'link', type: 'link',
}); });
} }
return actions; return actions;
},
}, },
}; },
};
</script> </script>
<template> <template>
<div class="js-build-header build-header top-area"> <header>
<ci-header <div class="js-build-header build-header top-area">
v-if="shouldRenderContent" <ci-header
:status="status" v-if="shouldRenderContent"
item-name="Job" :status="status"
:item-id="job.id" item-name="Job"
:time="job.created_at" :item-id="job.id"
:user="job.user" :time="job.created_at"
:actions="actions" :user="job.user"
:has-sidebar-button="true" :actions="actions"
:should-render-triggered-label="jobStarted" :has-sidebar-button="true"
/> :should-render-triggered-label="jobStarted"
<loading-icon />
v-if="isLoading" <loading-icon
size="2" v-if="isLoading"
class="prepend-top-default append-bottom-default" size="2"
class="prepend-top-default append-bottom-default"
/>
</div>
<callout
v-if="shouldRenderReason"
:message="job.callout_message"
/> />
</div> </header>
</template> </template>
<script> <script>
import detailRow from './sidebar_detail_row.vue'; import detailRow from './sidebar_detail_row.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago'; import timeagoMixin from '../../vue_shared/mixins/timeago';
import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; import { timeIntervalInWords } from '../../lib/utils/datetime_utility';
export default { export default {
name: 'SidebarDetailsBlock', name: 'SidebarDetailsBlock',
components: { components: {
detailRow, detailRow,
loadingIcon, loadingIcon,
},
mixins: [timeagoMixin],
props: {
job: {
type: Object,
required: true,
}, },
mixins: [ isLoading: {
timeagoMixin, type: Boolean,
], required: true,
props: {
job: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
runnerHelpUrl: {
type: String,
required: false,
default: '',
},
}, },
computed: { canUserRetry: {
shouldRenderContent() { type: Boolean,
return !this.isLoading && Object.keys(this.job).length > 0; required: false,
}, default: false,
coverage() { },
return `${this.job.coverage}%`; runnerHelpUrl: {
}, type: String,
duration() { required: false,
return timeIntervalInWords(this.job.duration); default: '',
}, },
queued() { },
return timeIntervalInWords(this.job.queued); computed: {
}, shouldRenderContent() {
runnerId() { return !this.isLoading && Object.keys(this.job).length > 0;
return `#${this.job.runner.id}`; },
}, coverage() {
hasTimeout() { return `${this.job.coverage}%`;
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; },
}, duration() {
timeout() { return timeIntervalInWords(this.job.duration);
if (this.job.metadata == null) { },
return ''; queued() {
} return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `#${this.job.runner.id}`;
},
retryButtonClass() {
let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block';
className +=
this.job.status && this.job.recoverable
? ' btn-primary'
: ' btn-inverted-secondary';
return className;
},
hasTimeout() {
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
},
timeout() {
if (this.job.metadata == null) {
return '';
}
let t = this.job.metadata.timeout_human_readable; let t = this.job.metadata.timeout_human_readable;
if (this.job.metadata.timeout_source !== '') { if (this.job.metadata.timeout_source !== '') {
t += ` (from ${this.job.metadata.timeout_source})`; t += ` (from ${this.job.metadata.timeout_source})`;
} }
return t; return t;
},
renderBlock() {
return this.job.merge_request ||
this.job.duration ||
this.job.finished_data ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length ||
this.job.cancel_path;
},
}, },
}; renderBlock() {
return (
this.job.merge_request ||
this.job.duration ||
this.job.finished_data ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length ||
this.job.cancel_path
);
},
},
};
</script> </script>
<template> <template>
<div> <div>
<div class="block">
<strong class="inline prepend-top-8">
{{ job.name }}
</strong>
<a
v-if="canUserRetry"
:class="retryButtonClass"
:href="job.retry_path"
data-method="post"
rel="nofollow"
>
{{ __('Retry') }}
</a>
<button
type="button"
:aria-label="__('Toggle Sidebar')"
class="btn btn-blank gutter-toggle pull-right
visible-xs-block visible-sm-block js-sidebar-build-toggle"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-angle-double-right"
></i>
</button>
</div>
<template v-if="shouldRenderContent"> <template v-if="shouldRenderContent">
<div <div
class="block retry-link" class="block retry-link"
...@@ -85,16 +124,16 @@ ...@@ -85,16 +124,16 @@
class="js-new-issue btn btn-new btn-inverted" class="js-new-issue btn btn-new btn-inverted"
:href="job.new_issue_path" :href="job.new_issue_path"
> >
New issue {{ __('New issue') }}
</a> </a>
<a <a
v-if="job.retry_path" v-if="canUserRetry"
class="js-retry-job btn btn-inverted-secondary" class="js-retry-job btn btn-inverted-secondary"
:href="job.retry_path" :href="job.retry_path"
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
> >
Retry {{ __('Retry') }}
</a> </a>
</div> </div>
<div :class="{block : renderBlock }"> <div :class="{block : renderBlock }">
...@@ -103,7 +142,7 @@ ...@@ -103,7 +142,7 @@
v-if="job.merge_request" v-if="job.merge_request"
> >
<span class="build-light-text"> <span class="build-light-text">
Merge Request: {{ __('Merge Request:') }}
</span> </span>
<a :href="job.merge_request.path"> <a :href="job.merge_request.path">
!{{ job.merge_request.iid }} !{{ job.merge_request.iid }}
...@@ -158,7 +197,7 @@ ...@@ -158,7 +197,7 @@
v-if="job.tags.length" v-if="job.tags.length"
> >
<span class="build-light-text"> <span class="build-light-text">
Tags: {{ __('Tags:') }}
</span> </span>
<span <span
v-for="(tag, i) in job.tags" v-for="(tag, i) in job.tags"
...@@ -178,7 +217,7 @@ ...@@ -178,7 +217,7 @@
data-method="post" data-method="post"
rel="nofollow" rel="nofollow"
> >
Cancel {{ __('Cancel') }}
</a> </a>
</div> </div>
</div> </div>
......
...@@ -35,9 +35,11 @@ export default () => { ...@@ -35,9 +35,11 @@ export default () => {
}); });
// Sidebar information block // Sidebar information block
const detailsBlockElement = document.getElementById('js-details-block-vue');
const detailsBlockDataset = detailsBlockElement.dataset;
// eslint-disable-next-line // eslint-disable-next-line
new Vue({ new Vue({
el: '#js-details-block-vue', el: detailsBlockElement,
components: { components: {
detailsBlock, detailsBlock,
}, },
...@@ -50,6 +52,7 @@ export default () => { ...@@ -50,6 +52,7 @@ export default () => {
return createElement('details-block', { return createElement('details-block', {
props: { props: {
isLoading: this.mediator.state.isLoading, isLoading: this.mediator.state.isLoading,
canUserRetry: !!('canUserRetry' in detailsBlockDataset),
job: this.mediator.store.state.job, job: this.mediator.store.state.job,
runnerHelpUrl: dataset.runnerHelpUrl, runnerHelpUrl: dataset.runnerHelpUrl,
}, },
......
<script>
const calloutVariants = ['danger', 'success', 'info', 'warning'];
export default {
props: {
category: {
type: String,
required: false,
default: calloutVariants[0],
validator: value => calloutVariants.includes(value),
},
message: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
:class="`bs-callout bs-callout-${category}`"
role="alert"
aria-live="assertive"
>
{{ message }}
</div>
</template>
@keyframes fade-out-status { @keyframes fade-out-status {
0%, 50% { opacity: 1; } 0%,
100% { opacity: 0; } 50% {
opacity: 1;
}
100% {
opacity: 0;
}
} }
@keyframes blinking-dots { @keyframes blinking-dots {
0% { 0% {
background-color: rgba($white-light, 1); background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
25% { 25% {
background-color: rgba($white-light, 0.4); background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 2), box-shadow: 12px 0 0 0 rgba($white-light, 2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
75% { 75% {
background-color: rgba($white-light, 0.4); background-color: rgba($white-light, 0.4);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 1); 24px 0 0 0 rgba($white-light, 1);
} }
100% { 100% {
background-color: rgba($white-light, 1); background-color: rgba($white-light, 1);
box-shadow: 12px 0 0 0 rgba($white-light, 0.2), box-shadow: 12px 0 0 0 rgba($white-light, 0.2),
24px 0 0 0 rgba($white-light, 0.2); 24px 0 0 0 rgba($white-light, 0.2);
} }
} }
@keyframes blinking-scroll-button { @keyframes blinking-scroll-button {
0% { opacity: 0.2; } 0% {
25% { opacity: 0.5; } opacity: 0.2;
50% { opacity: 0.7; } }
100% { opacity: 1; }
25% {
opacity: 0.5;
}
50% {
opacity: 0.7;
}
100% {
opacity: 1;
}
} }
.build-page { .build-page {
...@@ -125,12 +142,12 @@ ...@@ -125,12 +142,12 @@
.btn-scroll.animate { .btn-scroll.animate {
.first-triangle { .first-triangle {
animation: blinking-scroll-button 1s ease infinite; animation: blinking-scroll-button 1s ease infinite;
animation-delay: .3s; animation-delay: 0.3s;
} }
.second-triangle { .second-triangle {
animation: blinking-scroll-button 1s ease infinite; animation: blinking-scroll-button 1s ease infinite;
animation-delay: .2s; animation-delay: 0.2s;
} }
.third-triangle { .third-triangle {
......
...@@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController
result.merge!(trace.to_h) result.merge!(trace.to_h)
end end
result[:html] = result[:html].presence || 'No job log'
render json: result render json: result
end end
end end
......
module Ci module Ci
class BuildPresenter < Gitlab::View::Presenter::Delegated class BuildPresenter < Gitlab::View::Presenter::Delegated
CALLOUT_FAILURE_MESSAGES = {
unknown_failure: 'There is an unknown failure, please try again',
script_failure: 'There has been a script failure. Check the job log for more information',
api_failure: 'There has been an API failure, please try again',
stuck_or_timeout_failure: 'There has been a timeout failure or the job got stuck. Check your timeout limits or try again',
runner_system_failure: 'There has been a runner system failure, please try again',
missing_dependency_failure: 'There has been a missing dependency failure, check the job log for more information'
}.freeze
presents :build presents :build
def erased_by_user? def erased_by_user?
...@@ -35,6 +44,14 @@ module Ci ...@@ -35,6 +44,14 @@ module Ci
"#{subject.name} - #{detailed_status.status_tooltip}" "#{subject.name} - #{detailed_status.status_tooltip}"
end end
def callout_failure_message
CALLOUT_FAILURE_MESSAGES[failure_reason.to_sym]
end
def recoverable?
failed? && !unrecoverable?
end
private private
def tooltip_for_badge def tooltip_for_badge
...@@ -44,5 +61,9 @@ module Ci ...@@ -44,5 +61,9 @@ module Ci
def detailed_status def detailed_status
@detailed_status ||= subject.detailed_status(user) @detailed_status ||= subject.detailed_status(user)
end end
def unrecoverable?
script_failure? || missing_dependency_failure?
end
end end
end end
...@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity ...@@ -26,6 +26,8 @@ class JobEntity < Grape::Entity
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :detailed_status, as: :status, with: StatusEntity expose :detailed_status, as: :status, with: StatusEntity
expose :callout_message, if: -> (*) { failed? }
expose :recoverable, if: -> (*) { failed? }
private private
...@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity ...@@ -50,4 +52,20 @@ class JobEntity < Grape::Entity
def path_to(route, build) def path_to(route, build)
send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
end end
def failed?
build.failed?
end
def callout_message
build_presenter.callout_failure_message
end
def recoverable
build_presenter.recoverable?
end
def build_presenter
@build_presenter ||= build.present
end
end end
%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" } }
.sidebar-container .sidebar-container
.blocks-container .blocks-container
.block
%strong.inline.prepend-top-8
= @build.name
- if can?(current_user, :update_build, @build) && @build.retryable?
= link_to "Retry", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'js-retry-button pull-right btn btn-inverted-secondary btn-retry visible-md-block visible-lg-block', method: :post
%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 #js-details-block-vue{ data: { can_user_retry: can?(current_user, :update_build, @build) && @build.retryable? } }
- 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 .block
......
...@@ -75,7 +75,7 @@ cancel the job, retry it, or erase the job trace. ...@@ -75,7 +75,7 @@ cancel the job, retry it, or erase the job trace.
## Seeing the failure reason for jobs ## Seeing the failure reason for jobs
> [Introduced][ce-5742] in GitLab 10.7. > [Introduced][ce-17782] in GitLab 10.7.
When a pipeline fails or is allowed to fail, there are several places where you When a pipeline fails or is allowed to fail, there are several places where you
can quickly check the reason it failed: can quickly check the reason it failed:
...@@ -88,6 +88,8 @@ In any case, if you hover over the failed job you can see the reason it failed. ...@@ -88,6 +88,8 @@ In any case, if you hover over the failed job you can see the reason it failed.
![Pipeline detail](img/job_failure_reason.png) ![Pipeline detail](img/job_failure_reason.png)
From [GitLab 10.8][ce-17814] you can also see the reason it failed on the Job detail page.
## Pipeline graphs ## Pipeline graphs
> [Introduced][ce-5742] in GitLab 8.11. > [Introduced][ce-5742] in GitLab 8.11.
...@@ -279,4 +281,5 @@ runners will not use regular runners, they must be tagged accordingly. ...@@ -279,4 +281,5 @@ runners will not use regular runners, they must be tagged accordingly.
[ce-7931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7931 [ce-7931]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7931
[ce-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760 [ce-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760
[ce-17782]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17782 [ce-17782]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17782
[ce-17814]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/17814
[regexp]: https://gitlab.com/gitlab-org/gitlab-ce/blob/2f3dc314f42dbd79813e6251792853bc231e69dd/app/models/commit_status.rb#L99 [regexp]: https://gitlab.com/gitlab-org/gitlab-ce/blob/2f3dc314f42dbd79813e6251792853bc231e69dd/app/models/commit_status.rb#L99
...@@ -20,6 +20,10 @@ module Gitlab ...@@ -20,6 +20,10 @@ module Gitlab
subject subject
end end
def present(**attributes)
self
end
class_methods do class_methods do
def presenter? def presenter?
true true
......
...@@ -190,7 +190,10 @@ describe Projects::JobsController do ...@@ -190,7 +190,10 @@ describe Projects::JobsController do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(json_response['id']).to eq job.id expect(json_response['id']).to eq job.id
expect(json_response['status']).to eq job.status expect(json_response['status']).to eq job.status
expect(json_response['html']).to be_nil end
it 'returns no job log message' do
expect(json_response['html']).to eq('No job log')
end end
end end
......
...@@ -243,5 +243,10 @@ FactoryBot.define do ...@@ -243,5 +243,10 @@ FactoryBot.define do
failed failed
failure_reason 1 failure_reason 1
end end
trait :api_failure do
failed
failure_reason 2
end
end end
end end
...@@ -491,16 +491,18 @@ feature 'Jobs' do ...@@ -491,16 +491,18 @@ feature 'Jobs' do
end end
end end
describe "POST /:project/jobs/:id/retry" do describe "POST /:project/jobs/:id/retry", :js do
context "Job from project", :js do context "Job from project", :js do
before do before do
job.run! job.run!
job.cancel!
visit project_job_path(project, job) visit project_job_path(project, job)
find('.js-cancel-job').click() wait_for_requests
find('.js-retry-button').click find('.js-retry-button').click
end end
it 'shows the right status and buttons', :js do it 'shows the right status and buttons' do
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
......
...@@ -36,14 +36,28 @@ describe('Job details header', () => { ...@@ -36,14 +36,28 @@ describe('Job details header', () => {
}, },
isLoading: false, isLoading: false,
}; };
vm = mountComponent(HeaderComponent, props);
}); });
afterEach(() => { afterEach(() => {
vm.$destroy(); vm.$destroy();
}); });
describe('job reason', () => {
it('should not render the reason when reason is absent', () => {
vm = mountComponent(HeaderComponent, props);
expect(vm.shouldRenderReason).toBe(false);
});
it('should render the reason when reason is present', () => {
props.job.callout_message = 'There is an unknown failure, please try again';
vm = mountComponent(HeaderComponent, props);
expect(vm.shouldRenderReason).toBe(true);
});
});
describe('triggered job', () => { describe('triggered job', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(HeaderComponent, props); vm = mountComponent(HeaderComponent, props);
...@@ -51,14 +65,17 @@ describe('Job details header', () => { ...@@ -51,14 +65,17 @@ describe('Job details header', () => {
it('should render provided job information', () => { it('should render provided job information', () => {
expect( expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), vm.$el
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('failed Job #123 triggered 3 weeks ago by Foo'); ).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
}); });
it('should render new issue link', () => { it('should render new issue link', () => {
expect( expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
vm.$el.querySelector('.js-new-issue').getAttribute('href'), props.job.new_issue_path,
).toEqual(props.job.new_issue_path); );
}); });
}); });
...@@ -68,7 +85,10 @@ describe('Job details header', () => { ...@@ -68,7 +85,10 @@ describe('Job details header', () => {
vm = mountComponent(HeaderComponent, props); vm = mountComponent(HeaderComponent, props);
expect( expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), vm.$el
.querySelector('.header-main-content')
.textContent.replace(/\s+/g, ' ')
.trim(),
).toEqual('failed Job #123 created 3 weeks ago by Foo'); ).toEqual('failed Job #123 created 3 weeks ago by Foo');
}); });
}); });
......
...@@ -31,10 +31,25 @@ describe('Sidebar details block', () => { ...@@ -31,10 +31,25 @@ describe('Sidebar details block', () => {
}); });
}); });
describe("when user can't retry", () => {
it('should not render a retry button', () => {
vm = new SidebarComponent({
propsData: {
job: {},
canUserRetry: false,
isLoading: true,
},
}).$mount();
expect(vm.$el.querySelector('.js-retry-job')).toBeNull();
});
});
beforeEach(() => { beforeEach(() => {
vm = new SidebarComponent({ vm = new SidebarComponent({
propsData: { propsData: {
job, job,
canUserRetry: true,
isLoading: false, isLoading: false,
}, },
}).$mount(); }).$mount();
...@@ -42,7 +57,9 @@ describe('Sidebar details block', () => { ...@@ -42,7 +57,9 @@ describe('Sidebar details block', () => {
describe('actions', () => { describe('actions', () => {
it('should render link to new issue', () => { 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').getAttribute('href')).toEqual(
job.new_issue_path,
);
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue'); expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
}); });
...@@ -57,43 +74,35 @@ describe('Sidebar details block', () => { ...@@ -57,43 +74,35 @@ describe('Sidebar details block', () => {
describe('information', () => { describe('information', () => {
it('should render merge request link', () => { it('should render merge request link', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-mr'))).toEqual('Merge Request: !2');
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
).toEqual('Merge Request: !2');
expect( expect(vm.$el.querySelector('.js-job-mr a').getAttribute('href')).toEqual(
vm.$el.querySelector('.js-job-mr a').getAttribute('href'), job.merge_request.path,
).toEqual(job.merge_request.path); );
}); });
it('should render job duration', () => { it('should render job duration', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-duration'))).toEqual(
trimWhitespace(vm.$el.querySelector('.js-job-duration')), 'Duration: 6 seconds',
).toEqual('Duration: 6 seconds'); );
}); });
it('should render erased date', () => { it('should render erased date', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-erased'))).toEqual('Erased: 3 weeks ago');
trimWhitespace(vm.$el.querySelector('.js-job-erased')),
).toEqual('Erased: 3 weeks ago');
}); });
it('should render finished date', () => { it('should render finished date', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-finished'))).toEqual(
trimWhitespace(vm.$el.querySelector('.js-job-finished')), 'Finished: 3 weeks ago',
).toEqual('Finished: 3 weeks ago'); );
}); });
it('should render queued date', () => { it('should render queued date', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-queued'))).toEqual('Queued: 9 seconds');
trimWhitespace(vm.$el.querySelector('.js-job-queued')),
).toEqual('Queued: 9 seconds');
}); });
it('should render runner ID', () => { it('should render runner ID', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-runner'))).toEqual('Runner: #1');
trimWhitespace(vm.$el.querySelector('.js-job-runner')),
).toEqual('Runner: #1');
}); });
it('should render timeout information', () => { it('should render timeout information', () => {
...@@ -103,15 +112,11 @@ describe('Sidebar details block', () => { ...@@ -103,15 +112,11 @@ describe('Sidebar details block', () => {
}); });
it('should render coverage', () => { it('should render coverage', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-coverage'))).toEqual('Coverage: 20%');
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
).toEqual('Coverage: 20%');
}); });
it('should render tags', () => { it('should render tags', () => {
expect( expect(trimWhitespace(vm.$el.querySelector('.js-job-tags'))).toEqual('Tags: tag');
trimWhitespace(vm.$el.querySelector('.js-job-tags')),
).toEqual('Tags: tag');
}); });
}); });
}); });
import Vue from 'vue';
import callout from '~/vue_shared/components/callout.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
describe('Callout Component', () => {
let CalloutComponent;
let vm;
const exampleMessage = 'This is a callout message!';
beforeEach(() => {
CalloutComponent = Vue.extend(callout);
});
afterEach(() => {
vm.$destroy();
});
it('should render the appropriate variant of callout', () => {
vm = createComponent(CalloutComponent, {
category: 'info',
message: exampleMessage,
});
expect(vm.$el.getAttribute('class')).toEqual('bs-callout bs-callout-info');
expect(vm.$el.tagName).toEqual('DIV');
});
it('should render accessibility attributes', () => {
vm = createComponent(CalloutComponent, {
message: exampleMessage,
});
expect(vm.$el.getAttribute('role')).toEqual('alert');
expect(vm.$el.getAttribute('aria-live')).toEqual('assertive');
});
it('should render the provided message', () => {
vm = createComponent(CalloutComponent, {
message: exampleMessage,
});
expect(vm.$el.innerHTML.trim()).toEqual(exampleMessage);
});
});
...@@ -48,4 +48,11 @@ describe Gitlab::View::Presenter::Base do ...@@ -48,4 +48,11 @@ describe Gitlab::View::Presenter::Base do
end end
end end
end end
describe '#present' do
it 'returns self' do
presenter = presenter_class.new(build_stubbed(:project))
expect(presenter.present).to eq(presenter)
end
end
end end
...@@ -217,4 +217,39 @@ describe Ci::BuildPresenter do ...@@ -217,4 +217,39 @@ describe Ci::BuildPresenter do
end end
end end
end end
describe '#callout_failure_message' do
let(:build) { create(:ci_build, :failed, :script_failure) }
it 'returns a verbose failure reason' do
description = subject.callout_failure_message
expect(description).to eq('There has been a script failure. Check the job log for more information')
end
end
describe '#recoverable?' do
let(:build) { create(:ci_build, :failed, :script_failure) }
context 'when is a script or missing dependency failure' do
let(:failure_reasons) { %w(script_failure missing_dependency_failure) }
it 'should return false' do
failure_reasons.each do |failure_reason|
build.update_attribute(:failure_reason, failure_reason)
expect(presenter.recoverable?).to be_falsy
end
end
end
context 'when is any other failure type' do
let(:failure_reasons) { %w(unknown_failure api_failure stuck_or_timeout_failure runner_system_failure) }
it 'should return true' do
failure_reasons.each do |failure_reason|
build.update_attribute(:failure_reason, failure_reason)
expect(presenter.recoverable?).to be_truthy
end
end
end
end
end end
...@@ -133,22 +133,65 @@ describe JobEntity do ...@@ -133,22 +133,65 @@ describe JobEntity do
context 'when job failed' do context 'when job failed' do
let(:job) { create(:ci_build, :script_failure) } let(:job) { create(:ci_build, :script_failure) }
describe 'status' do it 'contains details' do
it 'should contain the failure reason inside label' do expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip end
expect(subject[:status][:label]).to eq('failed')
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure)') it 'states that it failed' do
end expect(subject[:status][:label]).to eq('failed')
end
it 'should indicate the failure reason on tooltip' do
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure)')
end
it 'should include a callout message with a verbose output' do
expect(subject[:callout_message]).to eq('There has been a script failure. Check the job log for more information')
end
it 'should state that it is not recoverable' do
expect(subject[:recoverable]).to be_falsy
end
end
context 'when job is allowed to fail' do
let(:job) { create(:ci_build, :allowed_to_fail, :script_failure) }
it 'contains details' do
expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
end
it 'states that it failed' do
expect(subject[:status][:label]).to eq('failed (allowed to fail)')
end
it 'should indicate the failure reason on tooltip' do
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure) (allowed to fail)')
end
it 'should include a callout message with a verbose output' do
expect(subject[:callout_message]).to eq('There has been a script failure. Check the job log for more information')
end
it 'should state that it is not recoverable' do
expect(subject[:recoverable]).to be_falsy
end
end
context 'when job failed and is recoverable' do
let(:job) { create(:ci_build, :api_failure) }
it 'should state it is recoverable' do
expect(subject[:recoverable]).to be_truthy
end end
end end
context 'when job passed' do context 'when job passed' do
let(:job) { create(:ci_build, :success) } let(:job) { create(:ci_build, :success) }
describe 'status' do it 'should not include callout message or recoverable keys' do
it 'should not contain the failure reason inside label' do expect(subject).not_to include('callout_message')
expect(subject[:status][:label]).to eq('passed') expect(subject).not_to include('recoverable')
end
end end
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