Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 39 additions & 22 deletions src/api/nes.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErro
return code;
}

export interface ScanProgressHandlers {
onReportCreated?: (reportId: string) => void;
onPageProgress?: (completedPageCount: number, totalPageCount: number) => void;
}

export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
return async (input: CreateEolReportInput): Promise<EolReport> => {
return async (input: CreateEolReportInput, handlers?: ScanProgressHandlers): Promise<EolReport> => {
let res: Awaited<ReturnType<typeof client.mutate<EolReportMutationResponse, { input: CreateEolReportInput }>>>;
res = await client.mutate<EolReportMutationResponse, { input: CreateEolReportInput }>({
mutation: createReportMutation,
Expand Down Expand Up @@ -49,31 +54,43 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
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<EolReportQueryResponse, { input: GetEolReportInput }>({
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<EolReportQueryResponse, { input: GetEolReportInput }>({
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<typeof client.query<EolReportQueryResponse, { input: GetEolReportInput }>>
>[];
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);
Expand Down Expand Up @@ -119,9 +136,9 @@ export class NesClient {
/**
* Submit a scan for a list of purls
*/
export function submitScan(input: CreateEolReportInput): Promise<EolReport> {
export function submitScan(input: CreateEolReportInput, handlers?: ScanProgressHandlers): Promise<EolReport> {
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);
}
37 changes: 33 additions & 4 deletions src/commands/scan/eol.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions test/api/nes.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }];
Expand Down
Loading