Skip to content
Snippets Groups Projects
Commit 452202e3 authored by Filipa Lacerda's avatar Filipa Lacerda Committed by Phil Hughes
Browse files

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

parent d25f6fcf
No related branches found
No related tags found
No related merge requests found
Showing
with 525 additions and 148 deletions
Loading
Loading
@@ -44,6 +44,7 @@ var config = {
groups_list: './groups_list.js',
issue_show: './issue_show/index.js',
integrations: './integrations',
job_details: './jobs/job_details_bundle.js',
locale: './locale/index.js',
main: './main.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
Loading
Loading
@@ -158,6 +159,7 @@ var config = {
'filtered_search',
'groups',
'issue_show',
'job_details',
'merge_conflicts',
'notebook_viewer',
'pdf_viewer',
Loading
Loading
Loading
Loading
@@ -27,6 +27,7 @@ Feature: Project Builds Permissions
When I visit project builds page
Then page status code should be 404
 
@javascript
Scenario: I try to visit build details of internal project with access to builds
Given The project is internal
And public access for builds is enabled
Loading
Loading
Loading
Loading
@@ -6,16 +6,19 @@ Feature: Project Builds Summary
And project has coverage enabled
And project has a recent build
 
@javascript
Scenario: I browse build details page
When I visit recent build details page
Then I see details of a build
And I see build trace
 
@javascript
Scenario: I browse project builds page
When I visit project builds page
Then I see coverage
Then I see button to CI Lint
 
@javascript
Scenario: I erase a build
Given recent build is successful
And recent build has a build trace
Loading
Loading
Loading
Loading
@@ -13,7 +13,7 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps
step 'I see button to CI Lint' do
page.within('.nav-controls') do
ci_lint_tool_link = page.find_link('CI lint')
expect(ci_lint_tool_link[:href]).to eq ci_lint_path
expect(ci_lint_tool_link[:href]).to end_with(ci_lint_path)
end
end
 
Loading
Loading
Loading
Loading
@@ -5,6 +5,7 @@ feature 'Jobs', :feature do
let(:user) { create(:user) }
let(:user_access_level) { :developer }
let(:project) { create(:project) }
let(:namespace) { project.namespace }
let(:pipeline) { create(:ci_pipeline, project: project) }
 
let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
Loading
Loading
@@ -113,10 +114,16 @@ feature 'Jobs', :feature do
 
describe "GET /:project/jobs/:id" do
context "Job from project" do
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
before do
visit namespace_project_job_path(project.namespace, project, build)
end
 
it 'shows status name', :js do
expect(page).to have_css('.ci-status.ci-success', text: 'passed')
end
it 'shows commit`s data' do
expect(page.status_code).to eq(200)
expect(page).to have_content pipeline.sha[0..7]
Loading
Loading
@@ -129,6 +136,48 @@ feature 'Jobs', :feature do
end
end
 
context 'when job is not running', :js do
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
before do
visit namespace_project_job_path(project.namespace, project, build)
end
it 'shows retry button' do
expect(page).to have_link('Retry')
end
context 'if build passed' do
it 'does not show New issue button' do
expect(page).not_to have_link('New issue')
end
end
context 'if build failed' do
let(:build) { create(:ci_build, :failed, pipeline: pipeline) }
before do
visit namespace_project_job_path(namespace, project, build)
end
it 'shows New issue button' do
expect(page).to have_link('New issue')
end
it 'links to issues/new with the title and description filled in' do
button_title = "Build Failed ##{build.id}"
build_path = namespace_project_job_path(namespace, project, build)
options = { issue: { title: button_title, description: build_path } }
href = new_namespace_project_issue_path(namespace, project, options)
page.within('.header-action-buttons') do
expect(find('.js-new-issue')['href']).to include(href)
end
end
end
end
context "Job from other project" do
before do
visit namespace_project_job_path(project.namespace, project, build2)
Loading
Loading
@@ -305,63 +354,38 @@ feature 'Jobs', :feature do
end
end
 
describe "POST /:project/jobs/:id/cancel" do
describe "POST /:project/jobs/:id/cancel", :js do
context "Job from project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link "Cancel"
find('.js-cancel-job').click()
end
 
it 'loads the page and shows all needed controls' do
expect(page.status_code).to eq(200)
expect(page).to have_content 'canceled'
expect(page).to have_content 'Retry'
end
end
context "Job from other project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2))
end
it { expect(page.status_code).to eq(404) }
end
end
 
describe "POST /:project/jobs/:id/retry" do
context "Job from project" do
context "Job from project", :js do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link 'Cancel'
page.within('.build-header') do
click_link 'Retry job'
end
find('.js-cancel-job').click()
find('.js-retry-button').trigger('click')
end
 
it 'shows the right status and buttons' do
it 'shows the right status and buttons', :js do
expect(page).to have_http_status(200)
expect(page).to have_content 'pending'
page.within('aside.right-sidebar') do
expect(page).to have_content 'Cancel'
end
end
end
 
context "Job from other project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link 'Cancel'
page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2))
end
it { expect(page).to have_http_status(404) }
end
context "Job that current user is not allowed to retry" do
before do
build.run!
Loading
Loading
@@ -435,20 +459,17 @@ feature 'Jobs', :feature do
Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
 
build.run!
allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths)
.and_return(paths)
visit namespace_project_job_path(project.namespace, project, build)
end
 
context 'when build has trace in file', :js do
let(:paths) do
[existing_file]
end
before do
find('.js-raw-link-controller').click()
allow_any_instance_of(Gitlab::Ci::Trace)
.to receive(:paths)
.and_return([existing_file])
visit namespace_project_job_path(namespace, project, build)
find('.js-raw-link-controller').click
end
 
it 'sends the right headers' do
Loading
Loading
@@ -458,11 +479,17 @@ feature 'Jobs', :feature do
end
end
 
context 'when job has trace in DB' do
let(:paths) { [] }
context 'when job has trace in the database', :js do
before do
allow_any_instance_of(Gitlab::Ci::Trace)
.to receive(:paths)
.and_return([])
visit namespace_project_job_path(namespace, project, build)
end
 
it 'sends the right headers' do
expect(page.status_code).not_to have_selector('.js-raw-link-controller')
expect(page).not_to have_selector('.js-raw-link-controller')
end
end
end
Loading
Loading
Loading
Loading
@@ -132,23 +132,6 @@ describe('Build', () => {
expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
});
it('reloads the page when the build is done', () => {
spyOn(gl.utils, 'visitUrl');
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
deferred.resolve({
html: '<span>Final</span>',
status: 'passed',
append: true,
complete: true,
});
this.build = new Build();
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL);
});
});
 
describe('truncated information', () => {
Loading
Loading
import '~/lib/utils/datetime_utility';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
 
(() => {
describe('Date time utils', () => {
Loading
Loading
@@ -82,4 +82,13 @@ import '~/lib/utils/datetime_utility';
});
});
});
describe('timeIntervalInWords', () => {
it('should return string with number of minutes and seconds', () => {
expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(timeIntervalInWords(1)).toEqual('1 second');
expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
});
});
})();
import Vue from 'vue';
import headerComponent from '~/jobs/components/header.vue';
describe('Job details header', () => {
let HeaderComponent;
let vm;
let props;
beforeEach(() => {
HeaderComponent = Vue.extend(headerComponent);
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
props = {
job: {
status: {
group: 'failed',
icon: 'ci-status-failed',
label: 'failed',
text: 'failed',
details_path: 'path',
},
id: 123,
created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'path',
new_issue_path: 'path',
},
isLoading: false,
};
vm = new HeaderComponent({ propsData: props }).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render provided job information', () => {
expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
});
it('should render retry link', () => {
expect(
vm.$el.querySelector('.js-retry-button').getAttribute('href'),
).toEqual(props.job.retry_path);
});
it('should render new issue link', () => {
expect(
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
).toEqual(props.job.new_issue_path);
});
});
import Vue from 'vue';
import JobMediator from '~/jobs/job_details_mediator';
import job from './mock_data';
describe('JobMediator', () => {
let mediator;
beforeEach(() => {
mediator = new JobMediator({ endpoint: 'foo' });
});
it('should set defaults', () => {
expect(mediator.store).toBeDefined();
expect(mediator.service).toBeDefined();
expect(mediator.options).toEqual({ endpoint: 'foo' });
expect(mediator.state.isLoading).toEqual(false);
});
describe('request and store data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(job), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
});
it('should store received data', (done) => {
mediator.fetchJob();
setTimeout(() => {
expect(mediator.store.state.job).toEqual(job);
done();
}, 0);
});
});
});
import JobStore from '~/jobs/stores/job_store';
import job from './mock_data';
describe('Job Store', () => {
let store;
beforeEach(() => {
store = new JobStore();
});
it('should set defaults', () => {
expect(store.state.job).toEqual({});
});
describe('storeJob', () => {
it('should store empty object if none is provided', () => {
store.storeJob();
expect(store.state.job).toEqual({});
});
it('should store provided argument', () => {
store.storeJob(job);
expect(store.state.job).toEqual(job);
});
});
});
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
export default {
id: 4757,
name: 'test',
build_path: '/root/ci-mock/-/jobs/4757',
retry_path: '/root/ci-mock/-/jobs/4757/retry',
cancel_path: '/root/ci-mock/-/jobs/4757/cancel',
new_issue_path: '/root/ci-mock/issues/new',
playable: false,
created_at: threeWeeksAgo.toISOString(),
updated_at: threeWeeksAgo.toISOString(),
finished_at: threeWeeksAgo.toISOString(),
queued: 9.54,
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/-/jobs/4757',
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
action: {
icon: 'icon_action_retry',
title: 'Retry',
path: '/root/ci-mock/-/jobs/4757/retry',
method: 'post',
},
},
coverage: 20,
erased_at: threeWeeksAgo.toISOString(),
duration: 6.785563,
tags: ['tag'],
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
erase_path: '/root/ci-mock/-/jobs/4757/erase',
artifacts: [null],
runner: {
id: 1,
description: 'local ci runner',
edit_path: '/root/ci-mock/runners/1/edit',
},
pipeline: {
id: 140,
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
active: false,
coverage: null,
source: 'unknown',
created_at: '2017-05-24T09:59:58.634Z',
updated_at: '2017-06-01T17:32:00.062Z',
path: '/root/ci-mock/pipelines/140',
flags: {
latest: true,
stuck: false,
yaml_errors: false,
retryable: false,
cancelable: false,
},
details: {
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/pipelines/140',
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
},
duration: 6,
finished_at: '2017-06-01T17:32:00.042Z',
},
ref: {
name: 'abc',
path: '/root/ci-mock/commits/abc',
tag: false,
branch: true,
},
commit: {
id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
short_id: 'c5864777',
title: 'Add new file',
created_at: '2017-05-24T10:59:52.000+01:00',
parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'],
message: 'Add new file',
author_name: 'Root',
author_email: 'admin@example.com',
authored_date: '2017-05-24T10:59:52.000+01:00',
committer_name: 'Root',
committer_email: 'admin@example.com',
committed_date: '2017-05-24T10:59:52.000+01:00',
author: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
commit_url: 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
},
},
merge_request: {
iid: 2,
path: '/root/ci-mock/merge_requests/2',
},
raw_path: '/root/ci-mock/builds/4757/raw',
};
import Vue from 'vue';
import sidebarDetailRow from '~/jobs/components/sidebar_detail_row.vue';
describe('Sidebar detail row', () => {
let SidebarDetailRow;
let vm;
beforeEach(() => {
SidebarDetailRow = Vue.extend(sidebarDetailRow);
});
afterEach(() => {
vm.$destroy();
});
it('should render no title', () => {
vm = new SidebarDetailRow({
propsData: {
value: 'this is the value',
},
}).$mount();
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('this is the value');
});
beforeEach(() => {
vm = new SidebarDetailRow({
propsData: {
title: 'this is the title',
value: 'this is the value',
},
}).$mount();
});
it('should render provided title and value', () => {
expect(
vm.$el.textContent.replace(/\s+/g, ' ').trim(),
).toEqual('this is the title: this is the value');
});
});
import Vue from 'vue';
import sidebarDetailsBlock from '~/jobs/components/sidebar_details_block.vue';
import job from './mock_data';
describe('Sidebar details block', () => {
let SidebarComponent;
let vm;
function trimWhitespace(element) {
return element.textContent.replace(/\s+/g, ' ').trim();
}
beforeEach(() => {
SidebarComponent = Vue.extend(sidebarDetailsBlock);
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
it('should render a loading spinner', () => {
vm = new SidebarComponent({
propsData: {
job: {},
isLoading: true,
},
}).$mount();
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
});
});
beforeEach(() => {
vm = new SidebarComponent({
propsData: {
job,
isLoading: false,
},
}).$mount();
});
describe('actions', () => {
it('should render link to new issue', () => {
expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(job.new_issue_path);
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
});
it('should render link to retry job', () => {
expect(vm.$el.querySelector('.js-retry-job').getAttribute('href')).toEqual(job.retry_path);
});
it('should render link to cancel job', () => {
expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
});
});
describe('information', () => {
it('should render merge request link', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
).toEqual('Merge Request: !2');
expect(
vm.$el.querySelector('.js-job-mr a').getAttribute('href'),
).toEqual(job.merge_request.path);
});
it('should render job duration', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-duration')),
).toEqual('Duration: 6 seconds');
});
it('should render erased date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-erased')),
).toEqual('Erased: 3 weeks ago');
});
it('should render finished date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-finished')),
).toEqual('Finished: 3 weeks ago');
});
it('should render queued date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-queued')),
).toEqual('Queued: 9 seconds');
});
it('should render runner ID', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-runner')),
).toEqual('Runner: #1');
});
it('should render coverage', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
).toEqual('Coverage: 20%');
});
it('should render tags', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-tags')),
).toEqual('Tags: tag');
});
});
});
Loading
Loading
@@ -43,6 +43,7 @@ describe('Header CI Component', () => {
isLoading: false,
},
],
hasSidebarButton: true,
};
 
vm = new HeaderCi({
Loading
Loading
@@ -90,4 +91,8 @@ describe('Header CI Component', () => {
done();
});
});
it('should render sidebar toggle button', () => {
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).toBeDefined();
});
});
Loading
Loading
@@ -2,12 +2,13 @@ require 'spec_helper'
 
describe BuildEntity do
let(:user) { create(:user) }
let(:build) { create(:ci_build, :failed) }
let(:build) { create(:ci_build) }
let(:project) { build.project }
let(:request) { double('request') }
 
before do
allow(request).to receive(:current_user).and_return(user)
project.add_developer(user)
end
 
let(:entity) do
Loading
Loading
@@ -16,9 +17,8 @@ describe BuildEntity do
 
subject { entity.as_json }
 
it 'contains paths to build page and retry action' do
expect(subject).to include(:build_path, :retry_path)
expect(subject[:retry_path]).not_to be_nil
it 'contains paths to build page action' do
expect(subject).to include(:build_path)
end
 
it 'does not contain sensitive information' do
Loading
Loading
@@ -39,12 +39,32 @@ describe BuildEntity do
expect(subject[:status]).to include :icon, :favicon, :text, :label
end
 
context 'when build is a regular job' do
context 'when build is retryable' do
before do
build.update(status: :failed)
end
it 'contains cancel path' do
expect(subject).to include(:retry_path)
end
end
context 'when build is cancelable' do
before do
build.update(status: :running)
end
it 'contains cancel path' do
expect(subject).to include(:cancel_path)
end
end
context 'when build is a regular build' do
it 'does not contain path to play action' do
expect(subject).not_to include(:play_path)
end
 
it 'is not a playable job' do
it 'is not a playable build' do
expect(subject[:playable]).to be false
end
end
Loading
Loading
Loading
Loading
@@ -15,36 +15,6 @@ describe 'projects/jobs/show', :view do
allow(view).to receive(:can?).and_return(true)
end
 
describe 'job information in header' do
let(:build) do
create(:ci_build, :success, environment: 'staging')
end
before do
render
end
it 'shows status name' do
expect(rendered).to have_css('.ci-status.ci-success', text: 'passed')
end
it 'does not render a link to the job' do
expect(rendered).not_to have_link('passed')
end
it 'shows job id' do
expect(rendered).to have_css('.js-build-id', text: build.id)
end
it 'shows a link to the pipeline' do
expect(rendered).to have_link(build.pipeline.id)
end
it 'shows a link to the commit' do
expect(rendered).to have_link(build.pipeline.short_sha)
end
end
describe 'environment info in job view' do
context 'job with latest deployment' do
let(:build) do
Loading
Loading
@@ -215,34 +185,6 @@ describe 'projects/jobs/show', :view do
end
end
 
context 'when job is not running' do
before do
build.success!
render
end
it 'shows retry button' do
expect(rendered).to have_link('Retry')
end
context 'if build passed' do
it 'does not show New issue button' do
expect(rendered).not_to have_link('New issue')
end
end
context 'if build failed' do
before do
build.status = 'failed'
render
end
it 'shows New issue button' do
expect(rendered).to have_link('New issue')
end
end
end
describe 'commit title in sidebar' do
let(:commit_title) { project.commit.title }
 
Loading
Loading
@@ -269,25 +211,4 @@ describe 'projects/jobs/show', :view do
expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2')
end
end
describe 'New issue button' do
before do
build.status = 'failed'
render
end
it 'links to issues/new with the title and description filled in' do
title = "Build Failed ##{build.id}"
build_url = namespace_project_job_url(project.namespace, project, build)
href = new_namespace_project_issue_path(
project.namespace,
project,
issue: {
title: title,
description: build_url
}
)
expect(rendered).to have_link('New issue', href: href)
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment