Add ability to retry scans from on-demand scans list

This adds the ability to retry scans that failed or succeeded with
warnings from the on-demand scans list.

Changelog: added
EE: true
parent ec0215b6
......@@ -975,6 +975,11 @@ To view running completed and scheduled on-demand DAST scans for a project, go t
To cancel a pending or running on-demand scan, select **Cancel** (**{cancel}**) in the
on-demand scans list.
#### Retry an on-demand scan
To retry a scan that failed or succeeded with warnings, select **Retry** (**{retry}**) in the
on-demand scans list.
### Run an on-demand DAST scan
Prerequisites:
......
<script>
import { GlButton, GlTooltip } from '@gitlab/ui';
import { uniqueId } from 'lodash';
export default {
components: {
GlButton,
GlTooltip,
},
props: {
actionType: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
buttonId: uniqueId(this.actionType),
};
},
methods: {
onClick() {
this.$root.$emit('bv::hide::tooltip', this.buttonId);
this.$emit('click');
},
},
};
</script>
<template>
<span>
<gl-button
:id="buttonId"
:aria-label="label"
:loading="isLoading"
:icon="actionType"
@click="onClick"
/>
<gl-tooltip ref="tooltip" :target="buttonId" placement="top" triggers="hover" noninteractive>
{{ label }}
</gl-tooltip>
</span>
</template>
<script>
import { GlButton, GlTooltip } from '@gitlab/ui';
import pipelineCancelMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import pipelineRetryMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import { __, s__ } from '~/locale';
import { PIPELINES_GROUP_RUNNING, PIPELINES_GROUP_PENDING } from '../constants';
import {
PIPELINES_GROUP_RUNNING,
PIPELINES_GROUP_PENDING,
PIPELINES_GROUP_SUCCESS_WITH_WARNINGS,
PIPELINES_GROUP_FAILED,
} from '../constants';
import ActionButton from './action_button.vue';
const CANCELLING_PROPERTY = 'isCancelling';
const RETRYING_PROPERTY = 'isRetrying';
function action({ loadingProperty, mutation, mutationType, defaultErrorMessage }) {
this.$emit('action');
this[loadingProperty] = true;
this.$apollo
.mutate({
mutation,
variables: {
id: this.scan.id,
},
update: (_store, { data = {} }) => {
const [errorMessage] = data[mutationType]?.errors ?? [];
if (errorMessage) {
this.triggerError(loadingProperty, errorMessage);
}
},
})
.catch((exception) => {
this.triggerError(loadingProperty, defaultErrorMessage, exception);
});
}
export const cancelError = s__('OnDemandScans|The scan could not be canceled.');
export const retryError = s__('OnDemandScans|The scan could not be retried.');
export default {
components: {
GlButton,
GlTooltip,
ActionButton,
},
props: {
scan: {
......@@ -22,6 +51,7 @@ export default {
data() {
return {
[CANCELLING_PROPERTY]: false,
[RETRYING_PROPERTY]: false,
};
},
computed: {
......@@ -30,28 +60,34 @@ export default {
this.scan?.detailedStatus?.group,
);
},
isRetryable() {
return [PIPELINES_GROUP_SUCCESS_WITH_WARNINGS, PIPELINES_GROUP_FAILED].includes(
this.scan?.detailedStatus?.group,
);
},
},
watch: {
'scan.detailedStatus.group': function detailedStatusGroupWatcher() {
this[CANCELLING_PROPERTY] = false;
this[RETRYING_PROPERTY] = false;
},
},
methods: {
cancelPipeline() {
this.$emit('action');
this[CANCELLING_PROPERTY] = true;
this.$apollo
.mutate({
mutation: pipelineCancelMutation,
variables: {
id: this.scan.id,
},
update: (_store, { data = {} }) => {
const [errorMessage] = data.pipelineCancel?.errors ?? [];
if (errorMessage) {
this.triggerError(CANCELLING_PROPERTY, errorMessage);
}
},
})
.catch((exception) => {
this.triggerError(CANCELLING_PROPERTY, this.$options.i18n.cancelError, exception);
});
action.call(this, {
loadingProperty: CANCELLING_PROPERTY,
mutation: pipelineCancelMutation,
mutationType: 'pipelineCancel',
defaultErrorMessage: this.$options.i18n.cancelError,
});
},
retryPipeline() {
action.call(this, {
loadingProperty: RETRYING_PROPERTY,
mutation: pipelineRetryMutation,
mutationType: 'pipelineRetry',
defaultErrorMessage: this.$options.i18n.retryError,
});
},
triggerError(loadingProperty, message, exception) {
this[loadingProperty] = false;
......@@ -61,29 +97,29 @@ export default {
i18n: {
cancel: __('Cancel'),
cancelError,
retry: __('Retry'),
retryError,
},
};
</script>
<template>
<div class="gl-text-right">
<template v-if="isCancellable">
<gl-button
:id="`cancel-button-${scan.id}`"
:aria-label="$options.i18n.cancel"
:loading="isCancelling"
icon="cancel"
data-testid="cancel-scan-button"
@click="cancelPipeline"
/>
<gl-tooltip
:target="`cancel-button-${scan.id}`"
placement="top"
triggers="hover"
noninteractive
>
{{ $options.i18n.cancel }}
</gl-tooltip>
</template>
<ActionButton
v-if="isCancellable"
data-testid="cancel-scan-button"
action-type="cancel"
:label="$options.i18n.cancel"
:is-loading="isCancelling"
@click="cancelPipeline"
/>
<ActionButton
v-if="isRetryable"
data-testid="retry-scan-button"
action-type="retry"
:label="$options.i18n.retry"
:is-loading="isRetrying"
@click="retryPipeline"
/>
</div>
</template>
......@@ -289,7 +289,12 @@ export default {
</template>
<template #cell(actions)="{ item }">
<actions :scan="item" @action="resetActionError" @error="handleActionError" />
<actions
:key="item.id"
:scan="item"
@action="resetActionError"
@error="handleActionError"
/>
</template>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope">
......
......@@ -21,6 +21,8 @@ export const PIPELINES_SCOPE_FINISHED = 'FINISHED';
// Pipeline statuses
export const PIPELINES_GROUP_RUNNING = 'running';
export const PIPELINES_GROUP_PENDING = 'pending';
export const PIPELINES_GROUP_SUCCESS_WITH_WARNINGS = 'success-with-warnings';
export const PIPELINES_GROUP_FAILED = 'success-with-warnings';
const STATUS_COLUMN = {
label: __('Status'),
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Actions doesn't render anything if the scan status is not supported 1`] = `
<div
class="gl-text-right"
>
<!---->
<!---->
</div>
`;
import { GlButton, GlTooltip } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ActionButton from 'ee/on_demand_scans/components/action_button.vue';
describe('ActionButton', () => {
let wrapper;
// Props
const actionType = 'action';
const label = 'Action label';
// Finders
const findButton = () => wrapper.findComponent(GlButton);
const findTooltip = () => wrapper.findComponent(GlTooltip);
const createComponent = () => {
wrapper = shallowMountExtended(ActionButton, {
propsData: {
actionType,
label,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders a button with a tooltip attached', () => {
const button = findButton();
const tooltip = findTooltip();
expect(button.exists()).toBe(true);
expect(tooltip.exists()).toBe(true);
expect(tooltip.props('target')).toBe(button.attributes('id'));
});
it('sets the label on the button and in the tooltip', () => {
expect(findButton().attributes('aria-label')).toBe(label);
expect(findTooltip().text()).toBe(label);
});
it('emits bv::hide::tooltip and click events on click', () => {
jest.spyOn(wrapper.vm.$root, '$emit');
findButton().vm.$emit('click');
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::hide::tooltip', expect.any(String));
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('passes the loading state down to the button', async () => {
expect(findButton().props('loading')).toBe(false);
await wrapper.setProps({ isLoading: true });
expect(findButton().props('loading')).toBe(true);
});
});
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Actions, { cancelError } from 'ee/on_demand_scans/components/actions.vue';
import { PIPELINES_GROUP_RUNNING, PIPELINES_GROUP_PENDING } from 'ee/on_demand_scans/constants';
import Actions, { cancelError, retryError } from 'ee/on_demand_scans/components/actions.vue';
import {
PIPELINES_GROUP_RUNNING,
PIPELINES_GROUP_PENDING,
PIPELINES_GROUP_SUCCESS_WITH_WARNINGS,
PIPELINES_GROUP_FAILED,
} from 'ee/on_demand_scans/constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import pipelineCancelMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import pipelineRetryMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
......@@ -11,18 +17,19 @@ Vue.use(VueApollo);
// Dummy scans
const mockPipelineId = 'gid://gitlab/Ci::Pipeline/1';
const runningScan = {
const scanFactory = (group) => ({
id: mockPipelineId,
detailedStatus: {
group: PIPELINES_GROUP_RUNNING,
group,
},
};
const pendingScan = {
id: mockPipelineId,
detailedStatus: {
group: PIPELINES_GROUP_PENDING,
},
};
});
const runningScan = scanFactory(PIPELINES_GROUP_RUNNING);
const pendingScan = scanFactory(PIPELINES_GROUP_PENDING);
const successWithWarningsScan = scanFactory(PIPELINES_GROUP_SUCCESS_WITH_WARNINGS);
const failedScan = scanFactory(PIPELINES_GROUP_FAILED);
// Error messages
const errorAsDataMessage = 'Error as data';
describe('Actions', () => {
let wrapper;
......@@ -31,6 +38,7 @@ describe('Actions', () => {
// Finders
const findCancelScanButton = () => wrapper.findByTestId('cancel-scan-button');
const findRetryScanButton = () => wrapper.findByTestId('retry-scan-button');
// Helpers
const createMockApolloProvider = (mutation, handler) => {
......@@ -61,70 +69,69 @@ describe('Actions', () => {
},
});
expect(wrapper.element.childNodes).toHaveLength(1);
expect(wrapper.element.childNodes[0].tagName).toBeUndefined();
expect(wrapper.element).toMatchSnapshot();
});
describe.each`
scanStatus | scan
${'running'} | ${runningScan}
${'pending'} | ${pendingScan}
`('$scanStatus scan', ({ scan }) => {
it('renders a cancel button', () => {
scanStatus | scan | buttonFinder | mutation | mutationType | errorMessage
${'running'} | ${runningScan} | ${findCancelScanButton} | ${pipelineCancelMutation} | ${'pipelineCancel'} | ${cancelError}
${'pending'} | ${pendingScan} | ${findCancelScanButton} | ${pipelineCancelMutation} | ${'pipelineCancel'} | ${cancelError}
${'success with warnings'} | ${successWithWarningsScan} | ${findRetryScanButton} | ${pipelineRetryMutation} | ${'pipelineRetry'} | ${retryError}
${'failed'} | ${failedScan} | ${findRetryScanButton} | ${pipelineRetryMutation} | ${'pipelineRetry'} | ${retryError}
`('$scanStatus scan', ({ scan, buttonFinder, mutation, mutationType, errorMessage }) => {
it('renders the action button', () => {
createComponent(scan);
expect(findCancelScanButton().exists()).toBe(true);
expect(buttonFinder().exists()).toBe(true);
});
describe('when clicking on the cancel button', () => {
let cancelButton;
describe('when clicking on the button', () => {
let button;
beforeEach(() => {
createMockApolloProvider(
pipelineCancelMutation,
jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [] } } }),
mutation,
jest.fn().mockResolvedValue({ data: { [mutationType]: { errors: [] } } }),
);
createComponent(scan);
cancelButton = findCancelScanButton();
cancelButton.vm.$emit('click');
button = buttonFinder();
button.vm.$emit('click');
});
afterEach(() => {
cancelButton = null;
button = null;
});
it('trigger the pipelineCancel mutation on click', () => {
it(`triggers the ${mutationType} mutation on click`, () => {
expect(requestHandler).toHaveBeenCalled();
});
it('emits the action event and puts the button in the loading state on click', async () => {
expect(wrapper.emitted('action')).toHaveLength(1);
expect(cancelButton.props('loading')).toBe(true);
expect(button.props('isLoading')).toBe(true);
});
});
const errorAsDataMessage = 'Error as data';
describe.each`
errorType | eventPayload | handler
${'top-level error'} | ${[cancelError, expect.any(Error)]} | ${jest.fn().mockRejectedValue()}
${'error as data'} | ${[errorAsDataMessage, undefined]} | ${jest.fn().mockResolvedValue({ data: { pipelineCancel: { errors: [errorAsDataMessage] } } })}
errorType | eventPayload | handler
${'top-level error'} | ${[errorMessage, expect.any(Error)]} | ${jest.fn().mockRejectedValue()}
${'error as data'} | ${[errorAsDataMessage, undefined]} | ${jest.fn().mockResolvedValue({ data: { [mutationType]: { errors: [errorAsDataMessage] } } })}
`('on $errorType', ({ eventPayload, handler }) => {
let cancelButton;
let button;
beforeEach(() => {
createMockApolloProvider(pipelineCancelMutation, handler);
createMockApolloProvider(mutation, handler);
createComponent(scan);
cancelButton = findCancelScanButton();
cancelButton.vm.$emit('click');
button = buttonFinder();
button.vm.$emit('click');
return waitForPromises();
});
afterEach(() => {
cancelButton = null;
button = null;
});
it('removes the loading state once the mutation errors out', async () => {
expect(cancelButton.props('loading')).toBe(false);
expect(button.props('isLoading')).toBe(false);
});
it('emits the error', async () => {
......
......@@ -24484,6 +24484,9 @@ msgstr ""
msgid "OnDemandScans|The scan could not be canceled."
msgstr ""
msgid "OnDemandScans|The scan could not be retried."
msgstr ""
msgid "OnDemandScans|There are no finished scans."
msgstr ""
......
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