Commit 84b5f8a9 authored by David O'Regan's avatar David O'Regan

Merge branch '211339-forward-deployment-warn-users-on-retry' into 'master'

Prepare for "Forward deployment  - warn users on "Retry""

See merge request gitlab-org/gitlab!46328
parents ce9225c0 4331452c
<script>
import { isEmpty } from 'lodash';
import { mapActions, mapState } from 'vuex';
import { GlLink, GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { GlButton, GlIcon, GlLink } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import DetailRow from './sidebar_detail_row.vue';
import ArtifactsBlock from './artifacts_block.vue';
import TriggerBlock from './trigger_block.vue';
import CommitBlock from './commit_block.vue';
import StagesDropdown from './stages_dropdown.vue';
import JobsContainer from './jobs_container.vue';
import SidebarJobDetailsContainer from './sidebar_job_details_container.vue';
export default {
name: 'JobSidebar',
components: {
ArtifactsBlock,
CommitBlock,
DetailRow,
GlIcon,
TriggerBlock,
StagesDropdown,
JobsContainer,
GlLink,
GlButton,
SidebarJobDetailsContainer,
TooltipOnTruncate,
},
mixins: [timeagoMixin],
props: {
artifactHelpUrl: {
type: String,
......@@ -42,53 +38,12 @@ export default {
},
computed: {
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
coverage() {
return `${this.job.coverage}%`;
},
duration() {
return timeIntervalInWords(this.job.duration);
},
queued() {
return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `${this.job.runner.description} (#${this.job.runner.id})`;
},
retryButtonClass() {
let className = 'js-retry-button btn btn-retry';
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 += sprintf(__(` (from %{timeoutSource})`), {
timeoutSource: this.job.metadata.timeout_source,
});
}
return t;
},
renderBlock() {
return (
this.job.duration ||
this.job.finished_at ||
this.job.erased_at ||
this.job.queued ||
this.hasTimeout ||
this.job.runner ||
this.job.coverage ||
this.job.tags.length
);
},
hasArtifact() {
return !isEmpty(this.job.artifact);
},
......@@ -96,16 +51,10 @@ export default {
return !isEmpty(this.job.trigger);
},
hasStages() {
return (
(this.job &&
this.job.pipeline &&
this.job.pipeline.stages &&
this.job.pipeline.stages.length > 0) ||
false
);
return this.job?.pipeline?.stages?.length > 0;
},
commit() {
return this.job.pipeline && this.job.pipeline.commit ? this.job.pipeline.commit : {};
return this.job?.pipeline?.commit || {};
},
},
methods: {
......@@ -131,22 +80,22 @@ export default {
data-method="post"
data-qa-selector="retry_button"
rel="nofollow"
>{{ __('Retry') }}</gl-link
>
>{{ __('Retry') }}
</gl-link>
<gl-link
v-if="job.cancel_path"
:href="job.cancel_path"
class="js-cancel-job btn btn-default"
data-method="post"
rel="nofollow"
>{{ __('Cancel') }}</gl-link
>
>{{ __('Cancel') }}
</gl-link>
</div>
<gl-button
:aria-label="__('Toggle Sidebar')"
class="d-md-none gl-ml-2 js-sidebar-build-toggle"
category="tertiary"
class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle"
icon="chevron-double-lg-right"
@click="toggleSidebar"
/>
......@@ -158,77 +107,37 @@ export default {
:href="job.new_issue_path"
class="btn btn-success btn-inverted float-left mr-2"
data-testid="job-new-issue"
>{{ __('New issue') }}</gl-link
>
>{{ __('New issue') }}
</gl-link>
<gl-link
v-if="job.terminal_path"
:href="job.terminal_path"
class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
target="_blank"
>
{{ __('Debug') }} <gl-icon name="external-link" :size="14" />
{{ __('Debug') }}
<gl-icon :size="14" name="external-link" />
</gl-link>
</div>
<div v-if="renderBlock" class="block">
<detail-row
v-if="job.duration"
:value="duration"
class="js-job-duration"
title="Duration"
/>
<detail-row
v-if="job.finished_at"
:value="timeFormatted(job.finished_at)"
class="js-job-finished"
title="Finished"
/>
<detail-row
v-if="job.erased_at"
:value="timeFormatted(job.erased_at)"
class="js-job-erased"
title="Erased"
/>
<detail-row v-if="job.queued" :value="queued" class="js-job-queued" title="Queued" />
<detail-row
v-if="hasTimeout"
:help-url="runnerHelpUrl"
:value="timeout"
class="js-job-timeout"
title="Timeout"
/>
<detail-row v-if="job.runner" :value="runnerId" class="js-job-runner" title="Runner" />
<detail-row
v-if="job.coverage"
:value="coverage"
class="js-job-coverage"
title="Coverage"
/>
<p v-if="job.tags.length" class="build-detail-row js-job-tags">
<span class="font-weight-bold">{{ __('Tags:') }}</span>
<span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{
tag
}}</span>
</p>
</div>
<sidebar-job-details-container :runner-help-url="runnerHelpUrl" />
<artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" />
<trigger-block v-if="hasTriggers" :trigger="job.trigger" />
<commit-block
:is-last-block="hasStages"
:commit="commit"
:is-last-block="hasStages"
:merge-request="job.merge_request"
/>
<stages-dropdown
:stages="stages"
v-if="job.pipeline"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
:stages="stages"
@requestSidebarStageDropdown="fetchJobsForStage"
/>
</div>
<jobs-container v-if="jobs.length" :jobs="jobs" :job-id="job.id" />
<jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" />
</div>
</aside>
</template>
<script>
import { mapState } from 'vuex';
import DetailRow from './sidebar_detail_row.vue';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
export default {
name: 'SidebarJobDetailsContainer',
components: {
DetailRow,
},
mixins: [timeagoMixin],
props: {
runnerHelpUrl: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState(['job']),
coverage() {
return `${this.job.coverage}%`;
},
duration() {
return timeIntervalInWords(this.job.duration);
},
erasedAt() {
return this.timeFormatted(this.job.erased_at);
},
finishedAt() {
return this.timeFormatted(this.job.finished_at);
},
hasTags() {
return this.job?.tags?.length;
},
hasTimeout() {
return this.job?.metadata?.timeout_human_readable ?? false;
},
hasAnyDetail() {
return Boolean(
this.job.duration ||
this.job.finished_at ||
this.job.erased_at ||
this.job.queued ||
this.job.runner ||
this.job.coverage,
);
},
queued() {
return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `${this.job.runner.description} (#${this.job.runner.id})`;
},
shouldRenderBlock() {
return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
},
timeout() {
return `${this.job?.metadata?.timeout_human_readable}${this.timeoutSource}`;
},
timeoutSource() {
if (!this.job?.metadata?.timeout_source) {
return '';
}
return sprintf(__(` (from %{timeoutSource})`), {
timeoutSource: this.job.metadata.timeout_source,
});
},
},
};
</script>
<template>
<div v-if="shouldRenderBlock" class="block">
<detail-row v-if="job.duration" :value="duration" title="Duration" />
<detail-row
v-if="job.finished_at"
:value="finishedAt"
data-testid="job-finished"
title="Finished"
/>
<detail-row v-if="job.erased_at" :value="erasedAt" title="Erased" />
<detail-row v-if="job.queued" :value="queued" title="Queued" />
<detail-row
v-if="hasTimeout"
:help-url="runnerHelpUrl"
:value="timeout"
data-testid="job-timeout"
title="Timeout"
/>
<detail-row v-if="job.runner" :value="runnerId" title="Runner" />
<detail-row v-if="job.coverage" :value="coverage" title="Coverage" />
<p v-if="hasTags" class="build-detail-row" data-testid="job-tags">
<span class="font-weight-bold">{{ __('Tags:') }}</span>
<span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ tag }}</span>
</p>
</div>
</template>
......@@ -4,7 +4,7 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
export const hasUnmetPrerequisitesFailure = state =>
state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites';
state?.job?.failure_reason === 'unmet_prerequisites';
export const shouldRenderCalloutMessage = state =>
!isEmpty(state.job.status) && !isEmpty(state.job.callout_message);
......
import { shallowMount } from '@vue/test-utils';
import SidebarJobDetailsContainer from '~/jobs/components/sidebar_job_details_container.vue';
import DetailRow from '~/jobs/components/sidebar_detail_row.vue';
import createStore from '~/jobs/store';
import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
import job from '../mock_data';
describe('Sidebar Job Details Container', () => {
let store;
let wrapper;
const findJobTimeout = () => wrapper.findByTestId('job-timeout');
const findJobTags = () => wrapper.findByTestId('job-tags');
const findAllDetailsRow = () => wrapper.findAll(DetailRow);
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
wrapper = extendedWrapper(
shallowMount(SidebarJobDetailsContainer, {
propsData: props,
store,
stubs: {
DetailRow,
},
}),
);
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('when no details are available', () => {
it('should render an empty container', () => {
createWrapper();
expect(wrapper.isEmpty()).toBe(true);
});
});
describe('when some of the details are available', () => {
beforeEach(createWrapper);
it.each([
['duration', 'Duration: 6 seconds'],
['erased_at', 'Erased: 3 weeks ago'],
['finished_at', 'Finished: 3 weeks ago'],
['queued', 'Queued: 9 seconds'],
['runner', 'Runner: local ci runner (#1)'],
['coverage', 'Coverage: 20%'],
])('uses %s to render job-%s', async (detail, value) => {
await store.dispatch('receiveJobSuccess', { [detail]: job[detail] });
const detailsRow = findAllDetailsRow();
expect(wrapper.isEmpty()).toBe(false);
expect(detailsRow).toHaveLength(1);
expect(detailsRow.at(0).text()).toBe(value);
});
it('only renders tags', async () => {
const { tags } = job;
await store.dispatch('receiveJobSuccess', { tags });
const tagsComponent = findJobTags();
expect(wrapper.isEmpty()).toBe(false);
expect(tagsComponent.text()).toBe('Tags: tag');
});
});
describe('when all the info are available', () => {
it('renders all the details components', async () => {
createWrapper();
await store.dispatch('receiveJobSuccess', job);
expect(findAllDetailsRow()).toHaveLength(7);
});
});
describe('timeout', () => {
const {
metadata: { timeout_human_readable, timeout_source },
} = job;
beforeEach(createWrapper);
it('does not render if metadata is empty', async () => {
const metadata = {};
await store.dispatch('receiveJobSuccess', { metadata });
const detailsRow = findAllDetailsRow();
expect(wrapper.isEmpty()).toBe(true);
expect(detailsRow.exists()).toBe(false);
});
it('uses metadata to render timeout', async () => {
const metadata = { timeout_human_readable };
await store.dispatch('receiveJobSuccess', { metadata });
const detailsRow = findAllDetailsRow();
expect(wrapper.isEmpty()).toBe(false);
expect(detailsRow).toHaveLength(1);
expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s');
});
it('uses metadata to render timeout and the source', async () => {
const metadata = { timeout_human_readable, timeout_source };
await store.dispatch('receiveJobSuccess', { metadata });
const detailsRow = findAllDetailsRow();
expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)');
});
it('should not render when no time is provided', async () => {
const metadata = { timeout_source };
await store.dispatch('receiveJobSuccess', { metadata });
expect(findJobTimeout().exists()).toBe(false);
});
it('should pass the help URL', async () => {
const helpUrl = 'fakeUrl';
const props = { runnerHelpUrl: helpUrl };
createWrapper({ props });
await store.dispatch('receiveJobSuccess', { metadata: { timeout_human_readable } });
expect(findJobTimeout().props('helpUrl')).toBe(helpUrl);
});
});
});
import Vue from 'vue';
import sidebarDetailsBlock from '~/jobs/components/sidebar.vue';
import { shallowMount } from '@vue/test-utils';
import Sidebar from '~/jobs/components/sidebar.vue';
import StagesDropdown from '~/jobs/components/stages_dropdown.vue';
import JobsContainer from '~/jobs/components/jobs_container.vue';
import createStore from '~/jobs/store';
import job, { jobsInStage } from '../mock_data';
import { mountComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { trimText } from '../../helpers/text_helper';
import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
describe('Sidebar details block', () => {
const SidebarComponent = Vue.extend(sidebarDetailsBlock);
let vm;
let store;
let wrapper;
beforeEach(() => {
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
});
wrapper = extendedWrapper(
shallowMount(Sidebar, {
...props,
store,
}),
);
};
afterEach(() => {
vm.$destroy();
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('when there is no retry path retry', () => {
it('should not render a retry button', () => {
const copy = { ...job };
delete copy.retry_path;
store.dispatch('receiveJobSuccess', copy);
vm = mountComponentWithStore(SidebarComponent, {
store,
});
it('should not render a retry button', async () => {
createWrapper();
const copy = { ...job, retry_path: null };
await store.dispatch('receiveJobSuccess', copy);
expect(vm.$el.querySelector('.js-retry-button')).toBeNull();
expect(wrapper.find('.js-retry-button').exists()).toBe(false);
});
});
describe('without terminal path', () => {
it('does not render terminal link', () => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
it('does not render terminal link', async () => {
createWrapper();
await store.dispatch('receiveJobSuccess', job);
expect(vm.$el.querySelector('.js-terminal-link')).toBeNull();
expect(wrapper.find('.js-terminal-link').exists()).toBe(false);
});
});
describe('with terminal path', () => {
it('renders terminal link', () => {
store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
vm = mountComponentWithStore(SidebarComponent, {
store,
});
it('renders terminal link', async () => {
createWrapper();
await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull();
expect(wrapper.find('.js-terminal-link').exists()).toBe(true);
});
});
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
});
describe('actions', () => {
it('should render link to new issue', () => {
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual(
job.new_issue_path,
);
beforeEach(() => {
createWrapper();
return store.dispatch('receiveJobSuccess', job);
});
expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual(
'New issue',
);
it('should render link to new issue', () => {
expect(wrapper.findByTestId('job-new-issue').attributes('href')).toBe(job.new_issue_path);
expect(wrapper.find('[data-testid="job-new-issue"]').text()).toBe('New issue');
});
it('should render link to retry job', () => {
expect(vm.$el.querySelector('.js-retry-button').getAttribute('href')).toEqual(job.retry_path);
expect(wrapper.find('.js-retry-button').attributes('href')).toBe(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 job duration', () => {
expect(trimText(vm.$el.querySelector('.js-job-duration').textContent)).toEqual(
'Duration: 6 seconds',
);
});
it('should render erased date', () => {
expect(trimText(vm.$el.querySelector('.js-job-erased').textContent)).toEqual(
'Erased: 3 weeks ago',
);
});
it('should render finished date', () => {
expect(trimText(vm.$el.querySelector('.js-job-finished').textContent)).toEqual(
'Finished: 3 weeks ago',
);
});
it('should render queued date', () => {
expect(trimText(vm.$el.querySelector('.js-job-queued').textContent)).toEqual(
'Queued: 9 seconds',
);
});
it('should render runner ID', () => {
expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual(
'Runner: local ci runner (#1)',
);
});
it('should render timeout information', () => {
expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual(
'Timeout: 1m 40s (from runner)',
);
});
it('should render coverage', () => {
expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual(
'Coverage: 20%',
);
});
it('should render tags', () => {
expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag');
expect(wrapper.find('.js-cancel-job').attributes('href')).toBe(job.cancel_path);
});
});
describe('stages dropdown', () => {
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
createWrapper();
return store.dispatch('receiveJobSuccess', { ...job, stage: 'aStage' });
});
describe('with stages', () => {
beforeEach(() => {
vm = mountComponentWithStore(SidebarComponent, { store });
});
it('renders value provided as selectedStage as selected', () => {
expect(vm.$el.querySelector('.js-selected-stage').textContent.trim()).toEqual(
vm.selectedStage,
);
expect(wrapper.find(StagesDropdown).props('selectedStage')).toBe('aStage');
});
});
describe('without jobs for stages', () => {
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
vm = mountComponentWithStore(SidebarComponent, { store });
});
beforeEach(() => store.dispatch('receiveJobSuccess', job));
it('does not render job container', () => {
expect(vm.$el.querySelector('.js-jobs-container')).toBeNull();
expect(wrapper.find('.js-jobs-container').exists()).toBe(false);
});
});
describe('with jobs for stages', () => {
beforeEach(() => {
store.dispatch('receiveJobSuccess', job);
store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
vm = mountComponentWithStore(SidebarComponent, { store });
beforeEach(async () => {
await store.dispatch('receiveJobSuccess', job);
await store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
});
it('renders list of jobs', () => {
expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull();
expect(wrapper.find(JobsContainer).exists()).toBe(true);
});
});
});
......
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