diff options
20 files changed, 517 insertions, 213 deletions
diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue index 357bc9aab17..21b545d6cab 100644 --- a/app/assets/javascripts/jobs/components/header.vue +++ b/app/assets/javascripts/jobs/components/header.vue @@ -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" - /> - <loading-icon - v-if="isLoading" - size="2" - class="prepend-top-default append-bottom-default" + <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" /> - </div> + </header> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index af47056d98f..4cd44bf7a76 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -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> diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js index 656676ead91..f2939ad4dbe 100644 --- a/app/assets/javascripts/jobs/job_details_bundle.js +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -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, }, diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue new file mode 100644 index 00000000000..ccf802c456c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/callout.vue @@ -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> diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 7a6352e45f1..50f32660445 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -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 { diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index dd12d30a085..7497b5012ec 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -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 diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index 9afebda19be..4873d7ce662 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -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 diff --git a/app/serializers/job_entity.rb b/app/serializers/job_entity.rb index 523b522d449..3076fed1674 100644 --- a/app/serializers/job_entity.rb +++ b/app/serializers/job_entity.rb @@ -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 diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 0b57ebedebd..7f0bef5ede0 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -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 diff --git a/doc/ci/pipelines.md b/doc/ci/pipelines.md index f14e0527998..b16cbc61d14 100644 --- a/doc/ci/pipelines.md +++ b/doc/ci/pipelines.md @@ -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 diff --git a/lib/gitlab/view/presenter/base.rb b/lib/gitlab/view/presenter/base.rb index 841fb681435..36162faa1eb 100644 --- a/lib/gitlab/view/presenter/base.rb +++ b/lib/gitlab/view/presenter/base.rb @@ -20,6 +20,10 @@ module Gitlab subject end + def present(**attributes) + self + end + class_methods do def presenter? true diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index b9a979044fe..f677cec3408 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -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 diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 33de637ff7d..4acc008ed38 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -243,5 +243,10 @@ FactoryBot.define do failed failure_reason 1 end + + trait :api_failure do + failed + failure_reason 2 + end end end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index a460024542c..a00db6dd161 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -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 diff --git a/spec/javascripts/jobs/header_spec.js b/spec/javascripts/jobs/header_spec.js index 0961605ce5c..4f861c39d3f 100644 --- a/spec/javascripts/jobs/header_spec.js +++ b/spec/javascripts/jobs/header_spec.js @@ -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'); }); }); diff --git a/spec/javascripts/jobs/sidebar_details_block_spec.js b/spec/javascripts/jobs/sidebar_details_block_spec.js index 602dae514b1..6b397c22fb9 100644 --- a/spec/javascripts/jobs/sidebar_details_block_spec.js +++ b/spec/javascripts/jobs/sidebar_details_block_spec.js @@ -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'); }); }); }); diff --git a/spec/javascripts/vue_shared/components/callout_spec.js b/spec/javascripts/vue_shared/components/callout_spec.js new file mode 100644 index 00000000000..e62bd86f4ca --- /dev/null +++ b/spec/javascripts/vue_shared/components/callout_spec.js @@ -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); + }); +}); diff --git a/spec/lib/gitlab/view/presenter/base_spec.rb b/spec/lib/gitlab/view/presenter/base_spec.rb index 32a946ca034..4eca53032a2 100644 --- a/spec/lib/gitlab/view/presenter/base_spec.rb +++ b/spec/lib/gitlab/view/presenter/base_spec.rb @@ -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 diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb index cc16d0f156b..4bc005df2fc 100644 --- a/spec/presenters/ci/build_presenter_spec.rb +++ b/spec/presenters/ci/build_presenter_spec.rb @@ -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 diff --git a/spec/serializers/job_entity_spec.rb b/spec/serializers/job_entity_spec.rb index 24a6f1a2a8a..c90396ebb28 100644 --- a/spec/serializers/job_entity_spec.rb +++ b/spec/serializers/job_entity_spec.rb @@ -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 |