Merge branch '44271-show-failure-reason-in-job-views-content-section' into 'master'
Resolve "Show `failure_reason` in jobs view content section" Closes #44271 See merge request gitlab-org/gitlab-ce!17814
This commit is contained in:
commit
ffde69d71e
20 changed files with 517 additions and 213 deletions
|
@ -1,82 +1,94 @@
|
|||
<script>
|
||||
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
|
||||
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
|
||||
import callout from '../../vue_shared/components/callout.vue';
|
||||
|
||||
export default {
|
||||
name: 'JobHeaderSection',
|
||||
components: {
|
||||
ciHeader,
|
||||
loadingIcon,
|
||||
export default {
|
||||
name: 'JobHeaderSection',
|
||||
components: {
|
||||
ciHeader,
|
||||
loadingIcon,
|
||||
callout,
|
||||
},
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
actions: this.getActions(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
actions: this.getActions(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
status() {
|
||||
return this.job && this.job.status;
|
||||
},
|
||||
computed: {
|
||||
status() {
|
||||
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;
|
||||
},
|
||||
shouldRenderContent() {
|
||||
return !this.isLoading && Object.keys(this.job).length;
|
||||
},
|
||||
watch: {
|
||||
job() {
|
||||
this.actions = this.getActions();
|
||||
},
|
||||
shouldRenderReason() {
|
||||
return !!(this.job.status && this.job.callout_message);
|
||||
},
|
||||
methods: {
|
||||
getActions() {
|
||||
const actions = [];
|
||||
/**
|
||||
* 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: {
|
||||
job() {
|
||||
this.actions = this.getActions();
|
||||
},
|
||||
},
|
||||
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: 'link',
|
||||
});
|
||||
}
|
||||
return 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: 'link',
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
</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"
|
||||
:has-sidebar-button="true"
|
||||
:should-render-triggered-label="jobStarted"
|
||||
<header>
|
||||
<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"
|
||||
:has-sidebar-button="true"
|
||||
:should-render-triggered-label="jobStarted"
|
||||
/>
|
||||
<loading-icon
|
||||
v-if="isLoading"
|
||||
size="2"
|
||||
class="prepend-top-default append-bottom-default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<callout
|
||||
v-if="shouldRenderReason"
|
||||
:message="job.callout_message"
|
||||
/>
|
||||
<loading-icon
|
||||
v-if="isLoading"
|
||||
size="2"
|
||||
class="prepend-top-default append-bottom-default"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
@ -1,80 +1,119 @@
|
|||
<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';
|
||||
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',
|
||||
components: {
|
||||
detailRow,
|
||||
loadingIcon,
|
||||
export default {
|
||||
name: 'SidebarDetailsBlock',
|
||||
components: {
|
||||
detailRow,
|
||||
loadingIcon,
|
||||
},
|
||||
mixins: [timeagoMixin],
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
mixins: [
|
||||
timeagoMixin,
|
||||
],
|
||||
props: {
|
||||
job: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
runnerHelpUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
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}`;
|
||||
},
|
||||
hasTimeout() {
|
||||
return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null;
|
||||
},
|
||||
timeout() {
|
||||
if (this.job.metadata == null) {
|
||||
return '';
|
||||
}
|
||||
canUserRetry: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
runnerHelpUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
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}`;
|
||||
},
|
||||
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;
|
||||
if (this.job.metadata.timeout_source !== '') {
|
||||
t += ` (from ${this.job.metadata.timeout_source})`;
|
||||
}
|
||||
let t = this.job.metadata.timeout_human_readable;
|
||||
if (this.job.metadata.timeout_source !== '') {
|
||||
t += ` (from ${this.job.metadata.timeout_source})`;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
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
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<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">
|
||||
<div
|
||||
class="block retry-link"
|
||||
|
@ -85,16 +124,16 @@
|
|||
class="js-new-issue btn btn-new btn-inverted"
|
||||
:href="job.new_issue_path"
|
||||
>
|
||||
New issue
|
||||
{{ __('New issue') }}
|
||||
</a>
|
||||
<a
|
||||
v-if="job.retry_path"
|
||||
v-if="canUserRetry"
|
||||
class="js-retry-job btn btn-inverted-secondary"
|
||||
:href="job.retry_path"
|
||||
data-method="post"
|
||||
rel="nofollow"
|
||||
>
|
||||
Retry
|
||||
{{ __('Retry') }}
|
||||
</a>
|
||||
</div>
|
||||
<div :class="{block : renderBlock }">
|
||||
|
@ -103,7 +142,7 @@
|
|||
v-if="job.merge_request"
|
||||
>
|
||||
<span class="build-light-text">
|
||||
Merge Request:
|
||||
{{ __('Merge Request:') }}
|
||||
</span>
|
||||
<a :href="job.merge_request.path">
|
||||
!{{ job.merge_request.iid }}
|
||||
|
@ -158,7 +197,7 @@
|
|||
v-if="job.tags.length"
|
||||
>
|
||||
<span class="build-light-text">
|
||||
Tags:
|
||||
{{ __('Tags:') }}
|
||||
</span>
|
||||
<span
|
||||
v-for="(tag, i) in job.tags"
|
||||
|
@ -178,7 +217,7 @@
|
|||
data-method="post"
|
||||
rel="nofollow"
|
||||
>
|
||||
Cancel
|
||||
{{ __('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -35,9 +35,11 @@ export default () => {
|
|||
});
|
||||
|
||||
// Sidebar information block
|
||||
const detailsBlockElement = document.getElementById('js-details-block-vue');
|
||||
const detailsBlockDataset = detailsBlockElement.dataset;
|
||||
// eslint-disable-next-line
|
||||
new Vue({
|
||||
el: '#js-details-block-vue',
|
||||
el: detailsBlockElement,
|
||||
components: {
|
||||
detailsBlock,
|
||||
},
|
||||
|
@ -50,6 +52,7 @@ export default () => {
|
|||
return createElement('details-block', {
|
||||
props: {
|
||||
isLoading: this.mediator.state.isLoading,
|
||||
canUserRetry: !!('canUserRetry' in detailsBlockDataset),
|
||||
job: this.mediator.store.state.job,
|
||||
runnerHelpUrl: dataset.runnerHelpUrl,
|
||||
},
|
||||
|
|
27
app/assets/javascripts/vue_shared/components/callout.vue
Normal file
27
app/assets/javascripts/vue_shared/components/callout.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<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>
|
|
@ -1,39 +1,56 @@
|
|||
@keyframes fade-out-status {
|
||||
0%, 50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blinking-dots {
|
||||
0% {
|
||||
background-color: rgba($white-light, 1);
|
||||
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% {
|
||||
background-color: rgba($white-light, 0.4);
|
||||
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% {
|
||||
background-color: rgba($white-light, 0.4);
|
||||
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% {
|
||||
background-color: rgba($white-light, 1);
|
||||
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 {
|
||||
0% { opacity: 0.2; }
|
||||
25% { opacity: 0.5; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.build-page {
|
||||
|
@ -125,12 +142,12 @@
|
|||
.btn-scroll.animate {
|
||||
.first-triangle {
|
||||
animation: blinking-scroll-button 1s ease infinite;
|
||||
animation-delay: .3s;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.second-triangle {
|
||||
animation: blinking-scroll-button 1s ease infinite;
|
||||
animation-delay: .2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.third-triangle {
|
||||
|
|
|
@ -78,6 +78,8 @@ class Projects::JobsController < Projects::ApplicationController
|
|||
result.merge!(trace.to_h)
|
||||
end
|
||||
|
||||
result[:html] = result[:html].presence || 'No job log'
|
||||
|
||||
render json: result
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
module Ci
|
||||
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
|
||||
|
||||
def erased_by_user?
|
||||
|
@ -35,6 +44,14 @@ module Ci
|
|||
"#{subject.name} - #{detailed_status.status_tooltip}"
|
||||
end
|
||||
|
||||
def callout_failure_message
|
||||
CALLOUT_FAILURE_MESSAGES[failure_reason.to_sym]
|
||||
end
|
||||
|
||||
def recoverable?
|
||||
failed? && !unrecoverable?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def tooltip_for_badge
|
||||
|
@ -44,5 +61,9 @@ module Ci
|
|||
def detailed_status
|
||||
@detailed_status ||= subject.detailed_status(user)
|
||||
end
|
||||
|
||||
def unrecoverable?
|
||||
script_failure? || missing_dependency_failure?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -26,6 +26,8 @@ class JobEntity < Grape::Entity
|
|||
expose :created_at
|
||||
expose :updated_at
|
||||
expose :detailed_status, as: :status, with: StatusEntity
|
||||
expose :callout_message, if: -> (*) { failed? }
|
||||
expose :recoverable, if: -> (*) { failed? }
|
||||
|
||||
private
|
||||
|
||||
|
@ -50,4 +52,20 @@ class JobEntity < Grape::Entity
|
|||
def path_to(route, build)
|
||||
send("#{route}_path", build.project.namespace, build.project, build) # rubocop:disable GitlabSecurity/PublicSend
|
||||
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
|
||||
|
|
|
@ -1,15 +1,8 @@
|
|||
%aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } }
|
||||
.sidebar-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?)
|
||||
.block
|
||||
|
|
|
@ -75,7 +75,7 @@ cancel the job, retry it, or erase the job trace.
|
|||
|
||||
## 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
|
||||
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.
|
|||
|
||||
![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
|
||||
|
||||
> [Introduced][ce-5742] in GitLab 8.11.
|
||||
|
@ -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-9760]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9760
|
||||
[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
|
||||
|
|
|
@ -20,6 +20,10 @@ module Gitlab
|
|||
subject
|
||||
end
|
||||
|
||||
def present(**attributes)
|
||||
self
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def presenter?
|
||||
true
|
||||
|
|
|
@ -190,7 +190,10 @@ describe Projects::JobsController do
|
|||
expect(response).to have_gitlab_http_status(:ok)
|
||||
expect(json_response['id']).to eq job.id
|
||||
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
|
||||
|
||||
|
|
|
@ -243,5 +243,10 @@ FactoryBot.define do
|
|||
failed
|
||||
failure_reason 1
|
||||
end
|
||||
|
||||
trait :api_failure do
|
||||
failed
|
||||
failure_reason 2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -491,16 +491,18 @@ feature 'Jobs' do
|
|||
end
|
||||
end
|
||||
|
||||
describe "POST /:project/jobs/:id/retry" do
|
||||
describe "POST /:project/jobs/:id/retry", :js do
|
||||
context "Job from project", :js do
|
||||
before do
|
||||
job.run!
|
||||
job.cancel!
|
||||
visit project_job_path(project, job)
|
||||
find('.js-cancel-job').click()
|
||||
wait_for_requests
|
||||
|
||||
find('.js-retry-button').click
|
||||
end
|
||||
|
||||
it 'shows the right status and buttons', :js do
|
||||
it 'shows the right status and buttons' do
|
||||
page.within('aside.right-sidebar') do
|
||||
expect(page).to have_content 'Cancel'
|
||||
end
|
||||
|
|
|
@ -36,14 +36,28 @@ describe('Job details header', () => {
|
|||
},
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
vm = mountComponent(HeaderComponent, props);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(HeaderComponent, props);
|
||||
|
@ -51,14 +65,17 @@ describe('Job details header', () => {
|
|||
|
||||
it('should render provided job information', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('should render new issue link', () => {
|
||||
expect(
|
||||
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
|
||||
).toEqual(props.job.new_issue_path);
|
||||
expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(
|
||||
props.job.new_issue_path,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -68,7 +85,10 @@ describe('Job details header', () => {
|
|||
vm = mountComponent(HeaderComponent, props);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
vm = new SidebarComponent({
|
||||
propsData: {
|
||||
job,
|
||||
canUserRetry: true,
|
||||
isLoading: false,
|
||||
},
|
||||
}).$mount();
|
||||
|
@ -42,7 +57,9 @@ describe('Sidebar details block', () => {
|
|||
|
||||
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').getAttribute('href')).toEqual(
|
||||
job.new_issue_path,
|
||||
);
|
||||
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
|
||||
});
|
||||
|
||||
|
@ -57,43 +74,35 @@ describe('Sidebar details block', () => {
|
|||
|
||||
describe('information', () => {
|
||||
it('should render merge request link', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
|
||||
).toEqual('Merge Request: !2');
|
||||
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);
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
expect(trimWhitespace(vm.$el.querySelector('.js-job-runner'))).toEqual('Runner: #1');
|
||||
});
|
||||
|
||||
it('should render timeout information', () => {
|
||||
|
@ -103,15 +112,11 @@ describe('Sidebar details block', () => {
|
|||
});
|
||||
|
||||
it('should render coverage', () => {
|
||||
expect(
|
||||
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
|
||||
).toEqual('Coverage: 20%');
|
||||
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');
|
||||
expect(trimWhitespace(vm.$el.querySelector('.js-job-tags'))).toEqual('Tags: tag');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
45
spec/javascripts/vue_shared/components/callout_spec.js
Normal file
45
spec/javascripts/vue_shared/components/callout_spec.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
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
|
|||
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
|
||||
|
|
|
@ -217,4 +217,39 @@ describe Ci::BuildPresenter do
|
|||
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
|
||||
|
|
|
@ -133,22 +133,65 @@ describe JobEntity do
|
|||
context 'when job failed' do
|
||||
let(:job) { create(:ci_build, :script_failure) }
|
||||
|
||||
describe 'status' do
|
||||
it 'should contain the failure reason inside label' do
|
||||
expect(subject[:status]).to include :icon, :favicon, :text, :label, :tooltip
|
||||
expect(subject[:status][:label]).to eq('failed')
|
||||
expect(subject[:status][:tooltip]).to eq('failed <br> (script failure)')
|
||||
end
|
||||
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')
|
||||
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
|
||||
|
||||
context 'when job passed' do
|
||||
let(:job) { create(:ci_build, :success) }
|
||||
|
||||
describe 'status' do
|
||||
it 'should not contain the failure reason inside label' do
|
||||
expect(subject[:status][:label]).to eq('passed')
|
||||
end
|
||||
it 'should not include callout message or recoverable keys' do
|
||||
expect(subject).not_to include('callout_message')
|
||||
expect(subject).not_to include('recoverable')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue