From d58f991ebcf13b3cc9d45816c7a6ef359d2af21e Mon Sep 17 00:00:00 2001 From: Venancio Orozco <4390221+v3nant@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:27:59 -0600 Subject: [PATCH] feat(scan): split scan spinner into report-creation and page-compiling stages --- src/api/nes.client.ts | 61 ++++++++++++++++++++++++------------- src/commands/scan/eol.ts | 37 +++++++++++++++++++--- test/api/nes.client.test.ts | 50 ++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 26 deletions(-) diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index 089927b6..af76c508 100644 --- a/src/api/nes.client.ts +++ b/src/api/nes.client.ts @@ -20,8 +20,13 @@ function extractErrorCode(errors: ReadonlyArray): ApiErro return code; } +export interface ScanProgressHandlers { + onReportCreated?: (reportId: string) => void; + onPageProgress?: (completedPageCount: number, totalPageCount: number) => void; +} + export const SbomScanner = (client: ReturnType) => { - return async (input: CreateEolReportInput): Promise => { + return async (input: CreateEolReportInput, handlers?: ScanProgressHandlers): Promise => { let res: Awaited>>; res = await client.mutate({ mutation: createReportMutation, @@ -49,31 +54,43 @@ export const SbomScanner = (client: ReturnType) => { throw new Error('Failed to create EOL report'); } + handlers?.onReportCreated?.(result.id); + const totalRecords = result.totalRecords || 0; - const totalPages = Math.ceil(totalRecords / config.pageSize); - const pages = Array.from({ length: totalPages }, (_, index) => - client.query({ - query: getEolReportQuery, - variables: { - input: { - id: result.id, - page: index + 1, - size: config.pageSize, - }, - }, - }), - ); + const totalPageCount = Math.ceil(totalRecords / config.pageSize); + const pageNumbers = Array.from({ length: totalPageCount }, (_, index) => index + 1); const components: EolReport['components'] = []; let reportMetadata: EolReport | null = null; + let completedPageCount = 0; + + handlers?.onPageProgress?.(completedPageCount, totalPageCount); + + for (let i = 0; i < pageNumbers.length; i += config.concurrentPageRequests) { + const batch = pageNumbers.slice(i, i + config.concurrentPageRequests); + const batchResponses = await Promise.all( + batch.map((page) => { + const pageResponse = client.query({ + query: getEolReportQuery, + variables: { + input: { + id: result.id, + page, + size: config.pageSize, + }, + }, + }); - for (let i = 0; i < pages.length; i += config.concurrentPageRequests) { - const batch = pages.slice(i, i + config.concurrentPageRequests); - let batchResponses: Awaited< - ReturnType> - >[]; + pageResponse + .catch(() => undefined) + .finally(() => { + completedPageCount += 1; + handlers?.onPageProgress?.(completedPageCount, totalPageCount); + }); - batchResponses = await Promise.all(batch); + return pageResponse; + }), + ); for (const response of batchResponses) { const queryErrors = getGraphQLErrors(response); @@ -119,9 +136,9 @@ export class NesClient { /** * Submit a scan for a list of purls */ -export function submitScan(input: CreateEolReportInput): Promise { +export function submitScan(input: CreateEolReportInput, handlers?: ScanProgressHandlers): Promise { const url = config.graphqlHost + config.graphqlPath; debugLogger('Submitting scan to %s', url); const client = new NesClient(url); - return client.startScan(input); + return client.startScan(input, handlers); } diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 6d8f1e22..8f17bd40 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -1,6 +1,7 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared'; import { trimCdxBom } from '@herodevs/eol-shared'; import { Command, Flags } from '@oclif/core'; +import { Presets, SingleBar } from 'cli-progress'; import ora from 'ora'; import { ApiError, PAYLOAD_TOO_LARGE_ERROR_CODE } from '../../api/errors.ts'; import { submitScan } from '../../api/nes.client.ts'; @@ -214,14 +215,42 @@ export default class ScanEol extends Command { })); } - spinner.start('Scanning for EOL packages'); + spinner.start('Scanning for EOL Packages'); + let reportCreated = false; + let progressBar: SingleBar | undefined; + try { const scanOrigin = flags.automated ? SCAN_ORIGIN_AUTOMATED : SCAN_ORIGIN_CLI; - const scan = await submitScan({ sbom: trimmedSbom, scanOrigin }); - spinner.succeed('Scan completed'); + const scan = await submitScan( + { sbom: trimmedSbom, scanOrigin }, + { + onReportCreated: (reportId) => { + reportCreated = true; + spinner.succeed(`Created Scan Report ${reportId}`); + }, + onPageProgress: (completedPageCount, totalPageCount) => { + if (!progressBar) { + progressBar = new SingleBar( + { format: 'Compiling Report Results {bar} | {value}/{total} pages', hideCursor: true }, + Presets.shades_grey, + ); + progressBar.start(totalPageCount, completedPageCount); + } else { + progressBar.update(completedPageCount); + } + + if (completedPageCount >= totalPageCount) { + progressBar.stop(); + } + }, + }, + ); return scan; } catch (error) { - spinner.fail('Scanning failed'); + progressBar?.stop(); + if (!reportCreated) { + spinner.fail('Failed to Create Scan Report'); + } const scanLoadTime = this.getScanLoadTime(scanStartTime); if (error instanceof ApiError) { diff --git a/test/api/nes.client.test.ts b/test/api/nes.client.test.ts index 2f072c85..f93fe9c5 100644 --- a/test/api/nes.client.test.ts +++ b/test/api/nes.client.test.ts @@ -175,6 +175,56 @@ describe('nes.client', () => { expect(vi.mocked(requireAccessTokenForScan).mock.calls).toEqual([[], [true]]); }); + describe('progress handlers', () => { + it('invokes onReportCreated with the report id and onPageProgress across paginated batches', async () => { + const components = [{ purl: 'pkg:npm/bootstrap@3.1.1', metadata: { isEol: true } }]; + + fetchMock + .addGraphQL({ + eol: { createReport: { success: true, id: 'test-progress', totalRecords: components.length } }, + }) + .addGraphQL({ + eol: { + report: { + id: 'test-progress', + createdOn: new Date().toISOString(), + metadata: {}, + components, + page: 1, + totalRecords: components.length, + }, + }, + }); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + + const onReportCreated = vi.fn(); + const onPageProgress = vi.fn(); + + await submitScan(input, { onReportCreated, onPageProgress }); + + expect(onReportCreated).toHaveBeenCalledExactlyOnceWith('test-progress'); + expect(onPageProgress).toHaveBeenCalledWith(0, 1); + expect(onPageProgress).toHaveBeenCalledWith(1, 1); + }); + + it('does not invoke onReportCreated when the createReport mutation fails', async () => { + fetchMock.addGraphQL({ + eol: { createReport: { success: false, id: null, totalRecords: 0 } }, + }); + + const input: CreateEolReportInput = { + sbom: { bomFormat: 'CycloneDX', components: [], specVersion: '1.4', version: 1 }, + }; + + const onReportCreated = vi.fn(); + await expect(submitScan(input, { onReportCreated })).rejects.toThrow(/Failed to create EOL report/); + expect(onReportCreated).not.toHaveBeenCalled(); + }); + }); + describe('scanOrigin', () => { it('passes scanOrigin to createReport mutation when provided', async () => { const components = [{ purl: 'pkg:npm/test@1.0.0', metadata: { isEol: false } }];