Commit ec34ff90 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '341380-dast-view-scans-retry-action' into 'master'

Add ability to retry scans from on-demand scans list

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