Commit 6656925d authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Natalia Tepluhina

Add "Running" and "Finished" on-demand scans tabs

parent 4c1a1d61
......@@ -9,5 +9,9 @@ module Routing
def graphql_etag_pipeline_sha_path(sha)
[api_graphql_path, "pipelines/sha/#{sha}"].join(':')
end
def graphql_etag_project_on_demand_scan_counts_path(project)
[api_graphql_path, "on_demand_scan/counts/#{project.full_path}"].join(':')
end
end
end
......@@ -60,6 +60,10 @@ module Ci
url_helpers.graphql_etag_pipeline_sha_path(sha)
end
def graphql_project_on_demand_scan_counts_path(project)
url_helpers.graphql_etag_project_on_demand_scan_counts_path(project)
end
# Updates ETag caches of a pipeline.
#
# This logic resides in a separate method so that EE can more easily extend
......@@ -82,6 +86,8 @@ module Ci
store.touch(graphql_pipeline_path(relative_pipeline))
store.touch(graphql_pipeline_sha_path(relative_pipeline.sha))
end
store.touch(graphql_project_on_demand_scan_counts_path(project))
end
def url_helpers
......
......@@ -2,8 +2,21 @@
import { GlButton, GlLink, GlSprintf, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import { HELP_PAGE_PATH } from '../constants';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import onDemandScanCounts from '../graphql/on_demand_scan_counts.query.graphql';
import {
HELP_PAGE_PATH,
PIPELINE_TABS_KEYS,
PIPELINES_COUNT_POLL_INTERVAL,
PIPELINES_SCOPE_RUNNING,
PIPELINES_SCOPE_FINISHED,
} from '../constants';
import AllTab from './tabs/all.vue';
import RunningTab from './tabs/running.vue';
import FinishedTab from './tabs/finished.vue';
import EmptyState from './empty_state.vue';
export default {
......@@ -15,14 +28,39 @@ export default {
GlTabs,
ConfigurationPageLayout,
AllTab,
RunningTab,
FinishedTab,
EmptyState,
},
inject: ['newDastScanPath'],
inject: ['newDastScanPath', 'projectPath', 'projectOnDemandScanCountsEtag'],
apollo: {
liveOnDemandScanCounts: {
query: onDemandScanCounts,
variables() {
return {
fullPath: this.projectPath,
runningScope: PIPELINES_SCOPE_RUNNING,
finishedScope: PIPELINES_SCOPE_FINISHED,
};
},
context() {
return getQueryHeaders(this.projectOnDemandScanCountsEtag);
},
update(data) {
return Object.fromEntries(
PIPELINE_TABS_KEYS.map((key) => {
const { count } = data[key].pipelines;
return [key, count];
}),
);
},
pollInterval: PIPELINES_COUNT_POLL_INTERVAL,
},
},
props: {
pipelinesCount: {
type: Number,
required: false,
default: 0,
initialOnDemandScanCounts: {
type: Object,
required: true,
},
},
data() {
......@@ -31,14 +69,25 @@ export default {
};
},
computed: {
onDemandScanCounts() {
return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts;
},
hasData() {
return this.pipelinesCount > 0;
return this.onDemandScanCounts.all > 0;
},
tabs() {
return {
all: {
component: AllTab,
itemsCount: this.pipelinesCount,
itemsCount: this.onDemandScanCounts.all,
},
running: {
component: RunningTab,
itemsCount: this.onDemandScanCounts.running,
},
finished: {
component: FinishedTab,
itemsCount: this.onDemandScanCounts.finished,
},
};
},
......@@ -61,6 +110,12 @@ export default {
this.activeTabIndex = tabIndex;
}
},
mounted() {
toggleQueryPollingByVisibility(
this.$apollo.queries.liveOnDemandScanCounts,
PIPELINES_COUNT_POLL_INTERVAL,
);
},
i18n: {
title: s__('OnDemandScans|On-demand scans'),
newScanButtonLabel: s__('OnDemandScans|New DAST scan'),
......@@ -93,9 +148,10 @@ export default {
<gl-tabs v-model="activeTab">
<component
:is="tab.component"
v-for="(tab, key) in tabs"
v-for="(tab, key, index) in tabs"
:key="key"
:items-count="tab.itemsCount"
:is-active="activeTab === index"
/>
</gl-tabs>
</configuration-page-layout>
......
<script>
import { __, s__ } from '~/locale';
import { __ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
......@@ -8,36 +9,7 @@ export default {
components: {
BaseTab,
},
tableFields: [
{
label: __('Status'),
key: 'detailedStatus',
columnClass: 'gl-w-15',
},
{
label: __('Name'),
key: 'dastProfile.name',
},
{
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
columnClass: 'gl-w-13',
},
{
label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: __('Start date'),
key: 'createdAt',
columnClass: 'gl-w-15',
},
{
label: __('Pipeline'),
key: 'id',
columnClass: 'gl-w-13',
},
],
tableFields: BASE_TABS_TABLE_FIELDS,
i18n: {
title: __('All'),
},
......
......@@ -44,10 +44,19 @@ export default {
},
inject: ['projectPath'],
props: {
isActive: {
type: Boolean,
required: true,
},
query: {
type: Object,
required: true,
},
queryVariables: {
type: Object,
required: false,
default: () => ({}),
},
title: {
type: String,
required: true,
......@@ -79,6 +88,7 @@ export default {
variables() {
return {
fullPath: this.projectPath,
...this.queryVariables,
...this.cursor,
};
},
......@@ -93,6 +103,9 @@ export default {
error() {
this.hasError = true;
},
skip() {
return !this.isActive;
},
pollInterval: PIPELINES_POLL_INTERVAL,
},
},
......@@ -132,6 +145,11 @@ export default {
},
},
watch: {
isActive(isActive) {
if (isActive) {
this.resetCursor();
}
},
hasPipelines(hasPipelines) {
if (this.hasError && hasPipelines) {
this.hasError = false;
......
<script>
import { __, s__ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS, PIPELINES_SCOPE_FINISHED, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
query: onDemandScansQuery,
queryVariables: {
scope: PIPELINES_SCOPE_FINISHED,
},
components: {
BaseTab,
},
tableFields: BASE_TABS_TABLE_FIELDS,
i18n: {
title: __('Finished'),
emptyStateTitle: s__('OnDemandScans|There are no finished scans.'),
emptyStateText: LEARN_MORE_TEXT,
},
};
</script>
<template>
<base-tab
:query="$options.query"
:query-variables="$options.queryVariables"
:title="$options.i18n.title"
:fields="$options.tableFields"
:empty-state-title="$options.i18n.emptyStateTitle"
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
/>
</template>
<script>
import { __, s__ } from '~/locale';
import onDemandScansQuery from '../../graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS, PIPELINES_SCOPE_RUNNING, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
query: onDemandScansQuery,
queryVariables: {
scope: PIPELINES_SCOPE_RUNNING,
},
components: {
BaseTab,
},
tableFields: BASE_TABS_TABLE_FIELDS,
i18n: {
title: __('Running'),
emptyStateTitle: s__('OnDemandScans|There are no running scans.'),
emptyStateText: LEARN_MORE_TEXT,
},
};
</script>
<template>
<base-tab
:query="$options.query"
:query-variables="$options.queryVariables"
:title="$options.i18n.title"
:fields="$options.tableFields"
:empty-state-title="$options.i18n.emptyStateTitle"
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
/>
</template>
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export const HELP_PAGE_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'on-demand-scans',
});
export const LEARN_MORE_TEXT = s__(
'OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}.',
);
export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished'];
export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000;
export const PIPELINES_COUNT_POLL_INTERVAL = 1000;
export const PIPELINES_SCOPE_RUNNING = 'RUNNING';
export const PIPELINES_SCOPE_FINISHED = 'FINISHED';
export const BASE_TABS_TABLE_FIELDS = [
{
label: __('Status'),
key: 'detailedStatus',
columnClass: 'gl-w-15',
},
{
label: __('Name'),
key: 'dastProfile.name',
},
{
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
columnClass: 'gl-w-13',
},
{
label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: __('Start date'),
key: 'createdAt',
columnClass: 'gl-w-15',
},
{
label: __('Pipeline'),
key: 'id',
columnClass: 'gl-w-13',
},
];
query onDemandScanCounts(
$fullPath: ID!
$runningScope: PipelineScopeEnum
$finishedScope: PipelineScopeEnum
) {
all: project(fullPath: $fullPath) {
pipelines(source: "ondemand_dast_scan") {
count
}
}
running: project(fullPath: $fullPath) {
pipelines(source: "ondemand_dast_scan", scope: $runningScope) {
count
}
}
finished: project(fullPath: $fullPath) {
pipelines(source: "ondemand_dast_scan", scope: $finishedScope) {
count
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query allPipelinesCount($fullPath: ID!, $first: Int, $last: Int, $after: String, $before: String) {
query onDemandScans(
$fullPath: ID!
$scope: PipelineScopeEnum
$first: Int
$last: Int
$after: String
$before: String
) {
project(fullPath: $fullPath) {
pipelines(
source: "ondemand_dast_scan"
scope: $scope
first: $first
last: $last
after: $after
......
......@@ -4,7 +4,12 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const defaultClient = createDefaultClient();
const defaultClient = createDefaultClient(
{},
{
useGet: true,
},
);
export default new VueApollo({
defaultClient,
......
......@@ -8,8 +8,13 @@ export default () => {
if (!el) {
return null;
}
const { pipelinesCount, projectPath, newDastScanPath, emptyStateSvgPath } = el.dataset;
const {
projectPath,
newDastScanPath,
emptyStateSvgPath,
projectOnDemandScanCountsEtag,
} = el.dataset;
const initialOnDemandScanCounts = JSON.parse(el.dataset.onDemandScanCounts);
return new Vue({
el,
......@@ -19,11 +24,12 @@ export default () => {
projectPath,
newDastScanPath,
emptyStateSvgPath,
projectOnDemandScanCountsEtag,
},
render(h) {
return h(OnDemandScans, {
props: {
pipelinesCount: Number(pipelinesCount),
initialOnDemandScanCounts,
},
});
},
......
......@@ -3,8 +3,16 @@
module Projects::OnDemandScansHelper
# rubocop: disable CodeReuse/ActiveRecord
def on_demand_scans_data(project)
on_demand_scans = project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan])
running_scan_count, finished_scan_count = count_running_and_finished_scans(on_demand_scans)
common_data(project).merge({
'pipelines-count' => project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan]).count,
'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path(project),
'on-demand-scan-counts' => {
all: on_demand_scans.length,
running: running_scan_count,
finished: finished_scan_count
}.to_json,
'new-dast-scan-path' => new_project_on_demand_scan_path(project),
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg')
})
......@@ -30,4 +38,19 @@ module Projects::OnDemandScansHelper
'project-path' => project.path_with_namespace
}
end
def count_running_and_finished_scans(on_demand_scans)
running_scan_count = 0
finished_scan_count = 0
on_demand_scans.each do |pipeline|
if %w[success failed canceled].include?(pipeline.status)
finished_scan_count += 1
elsif pipeline.status == "running"
running_scan_count += 1
end
end
[running_scan_count, finished_scan_count]
end
end
......@@ -12,13 +12,42 @@ RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
path = 'on_demand_scans/graphql/on_demand_scans.query.graphql'
before do
stub_licensed_features(security_on_demand_scans: true)
project.add_developer(current_user)
end
context 'project on demand scans count' do
path = 'on_demand_scans/graphql/on_demand_scan_counts.query.graphql'
let_it_be(:pipelines) do
[
create(:ci_pipeline, :success, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user),
create(:ci_pipeline, :failed, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user),
create(:ci_pipeline, :running, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user),
create(:ci_pipeline, :running, source: :ondemand_dast_scan, sha: project.commit.id, project: project, user: current_user)
]
end
it "graphql/#{path}.json" do
query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: {
fullPath: project.full_path,
runningScope: 'RUNNING',
finishedScope: 'FINISHED'
})
expect_graphql_errors_to_be_empty
expect(graphql_data_at(:all, :pipelines, :count)).to be(4)
expect(graphql_data_at(:running, :pipelines, :count)).to be(2)
expect(graphql_data_at(:finished, :pipelines, :count)).to be(2)
end
end
context 'pipelines list' do
path = 'on_demand_scans/graphql/on_demand_scans.query.graphql'
context 'with pipelines' do
let_it_be(:pipelines) do
create_list(
......@@ -60,4 +89,5 @@ RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do
end
end
end
end
end
import { GlSprintf, GlTabs } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { merge } from 'lodash';
import onDemandScansCountsMock from 'test_fixtures/graphql/on_demand_scans/graphql/on_demand_scan_counts.query.graphql.json';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import OnDemandScans from 'ee/on_demand_scans/components/on_demand_scans.vue';
import { PIPELINE_TABS_KEYS } from 'ee/on_demand_scans/constants';
import ConfigurationPageLayout from 'ee/security_configuration/components/configuration_page_layout.vue';
import { createRouter } from 'ee/on_demand_scans/router';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import EmptyState from 'ee/on_demand_scans/components/empty_state.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import onDemandScansCounts from 'ee/on_demand_scans/graphql/on_demand_scan_counts.query.graphql';
import flushPromises from 'helpers/flush_promises';
Vue.use(VueApollo);
describe('OnDemandScans', () => {
let wrapper;
let router;
let requestHandler;
// Props
const newDastScanPath = '/on_demand_scans/new';
const projectPath = '/namespace/project';
const projectOnDemandScanCountsEtag = `/api/graphql:on_demand_scan/counts/${projectPath}`;
const nonEmptyInitialPipelineCounts = {
all: 12,
running: 3,
finished: 9,
};
const emptyInitialPipelineCounts = Object.fromEntries(PIPELINE_TABS_KEYS.map((key) => [key, 0]));
// Finders
const findNewScanLink = () => wrapper.findByTestId('new-scan-link');
......@@ -21,14 +39,22 @@ describe('OnDemandScans', () => {
const findAllTab = () => wrapper.findComponent(AllTab);
const findEmptyState = () => wrapper.findComponent(EmptyState);
// Helpers
const createMockApolloProvider = () => {
return createMockApollo([[onDemandScansCounts, requestHandler]]);
};
const createComponent = (options = {}) => {
wrapper = shallowMountExtended(
OnDemandScans,
merge(
{
apolloProvider: createMockApolloProvider(),
router,
provide: {
newDastScanPath,
projectPath,
projectOnDemandScanCountsEtag,
},
stubs: {
ConfigurationPageLayout,
......@@ -36,12 +62,18 @@ describe('OnDemandScans', () => {
GlTabs,
},
},
{
propsData: {
initialOnDemandScanCounts: nonEmptyInitialPipelineCounts,
},
},
options,
),
);
};
beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(onDemandScansCountsMock);
router = createRouter();
});
......@@ -50,18 +82,35 @@ describe('OnDemandScans', () => {
});
it('renders an empty state when there is no data', () => {
createComponent();
createComponent({
propsData: {
initialOnDemandScanCounts: emptyInitialPipelineCounts,
},
});
expect(findEmptyState().exists()).toBe(true);
});
describe('when there is data', () => {
beforeEach(() => {
it('updates on-demand scans counts and shows the tabs once there is some data', async () => {
createComponent({
propsData: {
pipelinesCount: 12,
initialOnDemandScanCounts: emptyInitialPipelineCounts,
},
});
expect(findTabs().exists()).toBe(false);
expect(findEmptyState().exists()).toBe(true);
expect(requestHandler).toHaveBeenCalled();
await flushPromises();
expect(findTabs().exists()).toBe(true);
expect(findEmptyState().exists()).toBe(false);
});
describe('when there is data', () => {
beforeEach(() => {
createComponent();
});
it('renders a link to the docs', () => {
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AllTab renders the base tab with the correct props 1`] = `
Array [
Object {
"columnClass": "gl-w-15",
"key": "detailedStatus",
"label": "Status",
},
Object {
"key": "dastProfile.name",
"label": "Name",
},
Object {
"columnClass": "gl-w-13",
"key": "scanType",
"label": "Scan type",
},
Object {
"key": "dastProfile.dastSiteProfile.targetUrl",
"label": "Target",
},
Object {
"columnClass": "gl-w-15",
"key": "createdAt",
"label": "Start date",
},
Object {
"columnClass": "gl-w-13",
"key": "id",
"label": "Pipeline",
},
]
`;
import { shallowMount } from '@vue/test-utils';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
describe('AllTab', () => {
let wrapper;
// Finders
const findBaseTab = () => wrapper.findComponent(BaseTab);
const createComponent = (propsData) => {
wrapper = shallowMount(AllTab, {
propsData,
});
};
beforeEach(() => {
createComponent({
itemsCount: 12,
});
});
it('renders the base tab with the correct props', () => {
expect(findBaseTab().props('title')).toBe('All');
expect(findBaseTab().props('itemsCount')).toBe(12);
expect(findBaseTab().props('fields')).toMatchSnapshot();
});
});
......@@ -15,6 +15,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { scrollToElement } from '~/lib/utils/common_utils';
import setWindowLocation from 'helpers/set_window_location_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { BASE_TABS_TABLE_FIELDS, PIPELINES_POLL_INTERVAL } from 'ee/on_demand_scans/constants';
jest.mock('~/lib/utils/common_utils');
......@@ -41,11 +42,20 @@ describe('BaseTab', () => {
return createMockApollo([[onDemandScansQuery, requestHandler]]);
};
const navigateToPage = (direction) => {
findPagination().vm.$emit(direction);
const navigateToPage = (direction, cursor = '') => {
findPagination().vm.$emit(direction, cursor);
return wrapper.vm.$nextTick();
};
const setActiveState = (isActive) => {
wrapper.setProps({ isActive });
return wrapper.vm.$nextTick();
};
const advanceToNextFetch = () => {
jest.advanceTimersByTime(PIPELINES_POLL_INTERVAL);
};
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter();
wrapper = mountFn(
......@@ -56,35 +66,11 @@ describe('BaseTab', () => {
apolloProvider: createMockApolloProvider(),
router,
propsData: {
isActive: true,
title: 'All',
query: onDemandScansQuery,
itemsCount: 0,
fields: [
{
label: 'Status',
key: 'detailedStatus',
},
{
label: 'Name',
key: 'dastProfile.name',
},
{
label: 'OnDemandScans|Scan type',
key: 'scanType',
},
{
label: 'OnDemandScans|Target',
key: 'dastProfile.dastSiteProfile.targetUrl',
},
{
label: 'Start date',
key: 'createdAt',
},
{
label: 'Pipeline',
key: 'id',
},
],
fields: BASE_TABS_TABLE_FIELDS,
},
provide: {
projectPath,
......@@ -136,6 +122,27 @@ describe('BaseTab', () => {
});
});
it('polls for pipelines as long as the tab is active', async () => {
createComponent();
expect(requestHandler).toHaveBeenCalledTimes(1);
await wrapper.vm.$nextTick();
advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(2);
await setActiveState(false);
advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(2);
await setActiveState(true);
advanceToNextFetch();
expect(requestHandler).toHaveBeenCalledTimes(3);
});
it('puts the table in the busy state until the request resolves', async () => {
createComponent();
......@@ -206,6 +213,29 @@ describe('BaseTab', () => {
expect(Object.keys(router.currentRoute.query)).toContain('before');
expect(requestHandler).toHaveBeenCalledTimes(3);
});
it('when navigating to the next page, leaving the tab and coming back to it, the cursor is reset', async () => {
const { endCursor } = allPipelinesWithPipelinesMock.data.project.pipelines.pageInfo;
await navigateToPage('next', endCursor);
expect(requestHandler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
after: endCursor,
}),
);
await setActiveState(false);
await setActiveState(true);
advanceToNextFetch();
expect(requestHandler).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
after: null,
}),
);
});
});
describe('rendered cells', () => {
......
import { shallowMount } from '@vue/test-utils';
import AllTab from 'ee/on_demand_scans/components/tabs/all.vue';
import RunningTab from 'ee/on_demand_scans/components/tabs/running.vue';
import FinishedTab from 'ee/on_demand_scans/components/tabs/finished.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import onDemandScansQuery from 'ee/on_demand_scans/graphql/on_demand_scans.query.graphql';
import { BASE_TABS_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants';
describe.each`
tab | component | queryVariables | emptyTitle | emptyText
${'All'} | ${AllTab} | ${{}} | ${undefined} | ${undefined}
${'Running'} | ${RunningTab} | ${{ scope: 'RUNNING' }} | ${'There are no running scans.'} | ${LEARN_MORE_TEXT}
${'Finished'} | ${FinishedTab} | ${{ scope: 'FINISHED' }} | ${'There are no finished scans.'} | ${LEARN_MORE_TEXT}
`('$tab tab', ({ tab, component, queryVariables, emptyTitle, emptyText }) => {
let wrapper;
// Finders
const findBaseTab = () => wrapper.findComponent(BaseTab);
const createComponent = (propsData) => {
wrapper = shallowMount(component, {
propsData,
});
};
beforeEach(() => {
createComponent({
isActive: true,
itemsCount: 12,
});
});
it('renders the base tab with the correct props', () => {
expect(findBaseTab().props('title')).toBe(tab);
expect(findBaseTab().props('itemsCount')).toBe(12);
expect(findBaseTab().props('query')).toBe(onDemandScansQuery);
expect(findBaseTab().props('queryVariables')).toEqual(queryVariables);
expect(findBaseTab().props('emptyStateTitle')).toBe(emptyTitle);
expect(findBaseTab().props('emptyStateText')).toBe(emptyText);
expect(findBaseTab().props('fields')).toBe(BASE_TABS_TABLE_FIELDS);
});
});
......@@ -4,14 +4,18 @@ require 'spec_helper'
RSpec.describe Projects::OnDemandScansHelper do
let_it_be(:project) { create(:project) }
let_it_be(:path_with_namespace) { "foo/bar" }
let_it_be(:graphql_etag_project_on_demand_scan_counts_path) {"/api/graphql:#{path_with_namespace}/on_demand_scans/counts" }
before do
allow(project).to receive(:path_with_namespace).and_return("foo/bar")
allow(project).to receive(:path_with_namespace).and_return(path_with_namespace)
end
describe '#on_demand_scans_data' do
before do
create_list(:ci_pipeline, 12, project: project, ref: 'master', source: :ondemand_dast_scan)
create_list(:ci_pipeline, 8, :success, project: project, ref: 'master', source: :ondemand_dast_scan)
create_list(:ci_pipeline, 4, :running, project: project, ref: 'master', source: :ondemand_dast_scan)
allow(helper).to receive(:graphql_etag_project_on_demand_scan_counts_path).and_return(graphql_etag_project_on_demand_scan_counts_path)
end
it 'returns proper data' do
......@@ -19,7 +23,12 @@ RSpec.describe Projects::OnDemandScansHelper do
'project-path' => "foo/bar",
'new-dast-scan-path' => "/#{project.full_path}/-/on_demand_scans/new",
'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'),
'pipelines-count' => 12
'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path,
'on-demand-scan-counts' => {
all: 12,
running: 4,
finished: 8
}.to_json
)
end
end
......
......@@ -17,6 +17,11 @@ module Gitlab
%r(\Apipelines/sha/\w{7,40}\z),
'ci_editor',
'pipeline_authoring'
],
[
%r(\Aon_demand_scan/counts/),
'on_demand_scans',
'dynamic_application_security_testing'
]
].map(&method(:build_route)).freeze
......
......@@ -24122,6 +24122,9 @@ msgstr ""
msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}."
msgstr ""
msgid "OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}."
msgstr ""
msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later."
msgstr ""
......@@ -24215,6 +24218,12 @@ msgstr ""
msgid "OnDemandScans|Target"
msgstr ""
msgid "OnDemandScans|There are no finished scans."
msgstr ""
msgid "OnDemandScans|There are no running scans."
msgstr ""
msgid "OnDemandScans|Use existing scanner profile"
msgstr ""
......
......@@ -16,6 +16,7 @@ RSpec.describe Ci::ExpirePipelineCacheService do
pipeline_path = "/#{project.full_path}/-/pipelines/#{pipeline.id}.json"
graphql_pipeline_path = "/api/graphql:pipelines/id/#{pipeline.id}"
graphql_pipeline_sha_path = "/api/graphql:pipelines/sha/#{pipeline.sha}"
graphql_project_on_demand_scan_counts_path = "/api/graphql:on_demand_scan/counts/#{project.full_path}"
expect_next_instance_of(Gitlab::EtagCaching::Store) do |store|
expect(store).to receive(:touch).with(pipelines_path)
......@@ -23,6 +24,7 @@ RSpec.describe Ci::ExpirePipelineCacheService do
expect(store).to receive(:touch).with(pipeline_path)
expect(store).to receive(:touch).with(graphql_pipeline_path)
expect(store).to receive(:touch).with(graphql_pipeline_sha_path)
expect(store).to receive(:touch).with(graphql_project_on_demand_scan_counts_path)
end
subject.execute(pipeline)
......
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