diff --git a/.eslint_todo/vue-require-name-property.mjs b/.eslint_todo/vue-require-name-property.mjs
index ea735e87929d71b9cf20b705bf6a9d571c1a5e21..dc9a2f03a8d4b5a47b5b5d4d46c96c3358954daa 100644
--- a/.eslint_todo/vue-require-name-property.mjs
+++ b/.eslint_todo/vue-require-name-property.mjs
@@ -1944,7 +1944,6 @@ export default {
'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_counts.vue',
'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_filters.vue',
'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue',
- 'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue',
'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list_status.vue',
'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_report_header.vue',
'ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_report_tab.vue',
diff --git a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue
index e76b02bc7eebd84c6b68a7c26a79ddf4747d3a7c..34d759633ed6aad48885ee5a57e39e275370e69f 100644
--- a/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue
+++ b/ee/app/assets/javascripts/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue
@@ -128,6 +128,11 @@ export default {
sortDesc: true,
}),
},
+ loadingMoreCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -607,7 +612,13 @@ export default {
-
-
+
diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js
index 43716aca5bfa8e34e9702723c2d57a7b4c8dae75..f9ca89d4df6d1202646d4d32ce16a32c33827f3d 100644
--- a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js
+++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql_spec.js
@@ -5,9 +5,9 @@ import VueRouter from 'vue-router';
import { shallowMount } from '@vue/test-utils';
import VulnerabilityListGraphql, {
PAGE_SIZE_STORAGE_KEY,
+ MAX_GRAPHQL_REQUEST_SIZE,
} from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list_graphql.vue';
import VulnerabilityList from 'ee/security_dashboard/components/shared/vulnerability_report/vulnerability_list.vue';
-import { isPolicyViolationFilterEnabled } from 'ee/security_dashboard/utils';
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { DEFAULT_PAGE_SIZE } from '~/vue_shared/issuable/list/constants';
import groupVulnerabilitiesQuery from 'ee/security_dashboard/graphql/queries/group_vulnerabilities.query.graphql';
@@ -20,7 +20,6 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { vulnerabilities } from '../../mock_data';
jest.mock('~/alert');
-jest.mock('ee/security_dashboard/utils');
Vue.use(VueApollo);
Vue.use(VueRouter);
@@ -443,45 +442,232 @@ describe('Vulnerability list GraphQL component', () => {
expect(findLocalStorageSync().props('value')).toBe(pageSize);
});
+ });
+
+ describe('sequential batch loading', () => {
+ const generateVulnerabilities = (count) =>
+ Array.from({ length: count }, (_, i) => ({
+ ...vulnerabilities[0],
+ id: `id_${i}`,
+ project: { id: `project-${i}`, nameWithNamespace: 'Test / Project' },
+ }));
+
+ const createResponse = (nodes, pageInfo = {}) => ({
+ data: {
+ group: {
+ id: 'group-1',
+ __typename: 'Group',
+ vulnerabilities: {
+ nodes,
+ pageInfo: {
+ __typename: 'PageInfo',
+ startCursor: 'start',
+ endCursor: 'end',
+ hasNextPage: false,
+ hasPreviousPage: false,
+ ...pageInfo,
+ },
+ },
+ },
+ },
+ });
- it('is shown when loaded and there are no vulnerabilities', async () => {
- createWrapper({ vulnerabilitiesHandler: createVulnerabilitiesRequestHandler({}, []) });
+ it('when page size is less than or equal to MAX_GRAPHQL_REQUEST_SIZE, does not batch', async () => {
+ createWrapper();
await waitForPromises();
- expect(findPageSizeSelector().exists()).toBe(true);
+ expect(vulnerabilitiesRequestHandler).toHaveBeenCalledTimes(1);
});
- });
- describe('limit large page size', () => {
- const scenarios = [
- {
- description: 'when aiExperimentSastFpDetection is enabled',
- setup: () => ({ aiExperimentSastFpDetection: true }),
- },
- {
- description: 'when isPolicyViolationFilterEnabled() is true',
- setup: () => {
- isPolicyViolationFilterEnabled.mockReturnValue(true);
- return {};
- },
- },
- {
- description: 'when autoDismissVulnerabilityPolicies is enabled',
- setup: () => ({ autoDismissVulnerabilityPolicies: true }),
- },
- ];
+ describe('when page size is greater than MAX_GRAPHQL_REQUEST_SIZE', () => {
+ const BATCH_PAGE_SIZE = 100;
+
+ beforeEach(() => {
+ localStorage.setItem(PAGE_SIZE_STORAGE_KEY, BATCH_PAGE_SIZE);
+ });
+
+ it('uses MAX_GRAPHQL_REQUEST_SIZE for first request when page size exceeds it', async () => {
+ const handler = jest.fn().mockResolvedValue(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ endCursor: 'end-1',
+ hasNextPage: true,
+ }),
+ );
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(handler).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ first: MAX_GRAPHQL_REQUEST_SIZE }),
+ );
+ });
+
+ it('fetches second batch after first batch loads when batching is enabled', async () => {
+ const handler = jest
+ .fn()
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ endCursor: 'end-1',
+ hasNextPage: true,
+ }),
+ )
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ hasNextPage: false,
+ }),
+ );
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(handler).toHaveBeenCalledTimes(2);
+ expect(handler).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ after: 'end-1', first: MAX_GRAPHQL_REQUEST_SIZE }),
+ );
+ });
+
+ it('does not fetch second batch when first batch has fewer items than MAX_GRAPHQL_REQUEST_SIZE', async () => {
+ const handler = jest
+ .fn()
+ .mockResolvedValue(createResponse(generateVulnerabilities(30), { hasNextPage: false }));
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
- describe.each(scenarios)('$description', ({ setup }) => {
- it('sets excludeLargePageSize on page size selector', () => {
- createWrapper(setup());
- expect(findPageSizeSelector().props('excludeLargePageSize')).toBe(true);
+ it('passes loadingMoreCount to VulnerabilityList while loading second batch', async () => {
+ let resolveSecondBatch;
+ const handler = jest
+ .fn()
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ endCursor: 'end-1',
+ hasNextPage: true,
+ }),
+ )
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSecondBatch = resolve;
+ }),
+ );
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(findVulnerabilityList().props('loadingMoreCount')).toBe(
+ BATCH_PAGE_SIZE - MAX_GRAPHQL_REQUEST_SIZE,
+ );
+
+ resolveSecondBatch(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), { hasNextPage: false }),
+ );
+ await waitForPromises();
+
+ expect(findVulnerabilityList().props('loadingMoreCount')).toBe(0);
+ });
+
+ it('merges both batches into vulnerabilities', async () => {
+ const handler = jest
+ .fn()
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ endCursor: 'end-1',
+ hasNextPage: true,
+ }),
+ )
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ hasNextPage: false,
+ }),
+ );
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(findVulnerabilityList().props('vulnerabilities')).toHaveLength(
+ MAX_GRAPHQL_REQUEST_SIZE * 2,
+ );
+ });
+
+ it('uses MAX_GRAPHQL_REQUEST_SIZE for backward pagination when page size exceeds it', async () => {
+ const handler = jest.fn().mockResolvedValue(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ startCursor: 'start-1',
+ hasPreviousPage: true,
+ }),
+ );
+ await router.push({ query: { before: 'some-cursor' } });
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(handler).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ last: MAX_GRAPHQL_REQUEST_SIZE, first: null }),
+ );
+ });
+
+ it('fetches second batch for backward pagination when batching is enabled', async () => {
+ const handler = jest
+ .fn()
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ startCursor: 'start-1',
+ hasPreviousPage: true,
+ }),
+ )
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ hasPreviousPage: false,
+ }),
+ );
+ await router.push({ query: { before: 'some-cursor' } });
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(handler).toHaveBeenCalledTimes(2);
+ expect(handler).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({
+ before: 'start-1',
+ last: MAX_GRAPHQL_REQUEST_SIZE,
+ first: null,
+ }),
+ );
+ });
+
+ it('does not fetch second batch for backward pagination when first batch has fewer items', async () => {
+ const handler = jest
+ .fn()
+ .mockResolvedValue(
+ createResponse(generateVulnerabilities(30), { hasPreviousPage: false }),
+ );
+ await router.push({ query: { before: 'some-cursor' } });
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
+
+ expect(handler).toHaveBeenCalledTimes(1);
});
- it('changes page size from 100 to 50', () => {
- localStorage.setItem(PAGE_SIZE_STORAGE_KEY, 100);
- createWrapper(setup());
+ it('merges both batches correctly for backward pagination', async () => {
+ const handler = jest
+ .fn()
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ startCursor: 'start-1',
+ hasPreviousPage: true,
+ }),
+ )
+ .mockResolvedValueOnce(
+ createResponse(generateVulnerabilities(MAX_GRAPHQL_REQUEST_SIZE), {
+ hasPreviousPage: false,
+ }),
+ );
+ await router.push({ query: { before: 'some-cursor' } });
+ createWrapper({ vulnerabilitiesHandler: handler });
+ await waitForPromises();
- expect(findPageSizeSelector().props('value')).toBe(50);
+ expect(findVulnerabilityList().props('vulnerabilities')).toHaveLength(
+ MAX_GRAPHQL_REQUEST_SIZE * 2,
+ );
});
});
});
diff --git a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js
index e100def6475cea9c4a6d09bb83dfbd34f2cd9449..2d93046144fabc3dbbb27ce4b0cc71719fe8e11d 100644
--- a/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js
+++ b/ee/spec/frontend/security_dashboard/components/shared/vulnerability_report/vulnerability_list_spec.js
@@ -637,6 +637,36 @@ describe('Vulnerability list component', () => {
});
});
+ describe('loadingMoreCount prop', () => {
+ const findLoadingMoreSkeleton = () => wrapper.findByTestId('loading-more-skeleton');
+
+ it('does not show loading more skeletons when loadingMoreCount is `0`', () => {
+ createWrapper({ props: { vulnerabilities, loadingMoreCount: 0 } });
+
+ expect(findLoadingMoreSkeleton().exists()).toBe(false);
+ });
+
+ it('shows skeleton loaders when loadingMoreCount is greater than `0`', () => {
+ const loadingMoreCount = 50;
+ createWrapper({ props: { vulnerabilities, loadingMoreCount } });
+
+ expect(findLoadingMoreSkeleton().exists()).toBe(true);
+ expect(findLoadingMoreSkeleton().findAllComponents(GlSkeletonLoader)).toHaveLength(
+ loadingMoreCount,
+ );
+ });
+
+ it('shows vulnerabilities and loading skeletons together', () => {
+ const loadingMoreCount = 25;
+ createWrapper({ props: { vulnerabilities, loadingMoreCount } });
+
+ expect(findAllStatusCells()).toHaveLength(vulnerabilities.length);
+ expect(findLoadingMoreSkeleton().findAllComponents(GlSkeletonLoader)).toHaveLength(
+ loadingMoreCount,
+ );
+ });
+ });
+
describe('operational vulnerabilities', () => {
beforeEach(() => {
createWrapper({
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9d1307399db059629fd600526bb829a6d04f0fe9..ec9035189e621a14c49033e658b3e0478071ab2e 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -62592,6 +62592,9 @@ msgstr ""
msgid "SecurityReports|Enter at least 3 characters to view available identifiers."
msgstr ""
+msgid "SecurityReports|Error fetching additional vulnerabilities."
+msgstr ""
+
msgid "SecurityReports|Error fetching the vulnerability counts. Please check your network connection and try again."
msgstr ""