11import { doesNotThrow } from 'node:assert' ;
2- import { doesNotMatch , match , notStrictEqual , strictEqual } from 'node:assert/strict' ;
2+ import { doesNotMatch , match , notStrictEqual , ok , strictEqual } from 'node:assert/strict' ;
33import { exec } from 'node:child_process' ;
4- import { existsSync , readFileSync , unlinkSync , writeFileSync } from 'node:fs' ;
4+ import { randomUUID } from 'node:crypto' ;
5+ import { existsSync , readFileSync , rmSync , unlinkSync , writeFileSync } from 'node:fs' ;
56import { mkdir } from 'node:fs/promises' ;
7+ import { tmpdir } from 'node:os' ;
68import path from 'node:path' ;
79import { afterEach , beforeEach , describe , it } from 'node:test' ;
810import { promisify } from 'node:util' ;
@@ -181,6 +183,43 @@ describe('scan:eol e2e', () => {
181183 unlinkSync ( reportPath ) ;
182184 } ) ;
183185
186+ it ( 'warns and skips saving when --output is provided without --save' , async ( ) => {
187+ const customDir = path . join ( tmpdir ( ) , 'scan-eol-report-output' , randomUUID ( ) ) ;
188+ const customPath = path . join ( customDir , 'custom-report.json' ) ;
189+
190+ const cmd = `scan:eol --dir ${ simpleDir } --output ${ customPath } ` ;
191+ const { stderr } = await run ( cmd ) ;
192+
193+ const reportExists = existsSync ( customPath ) ;
194+ strictEqual ( reportExists , false , 'Custom report file should not be created without --save' ) ;
195+
196+ match ( stderr , / - - o u t p u t r e q u i r e s - - s a v e t o w r i t e t h e r e p o r t / i, 'Should warn that --output needs --save' ) ;
197+
198+ if ( existsSync ( customDir ) ) {
199+ rmSync ( customDir , { recursive : true , force : true } ) ;
200+ }
201+ } ) ;
202+
203+ it ( 'saves report to a custom path when --save and --output are provided' , async ( ) => {
204+ const customDir = path . join ( fixturesDir , 'outputs-save' ) ;
205+ const customPath = path . join ( customDir , 'custom-report.json' ) ;
206+ await mkdir ( customDir , { recursive : true } ) ;
207+
208+ const cmd = `scan:eol --dir ${ simpleDir } --save --output ${ customPath } ` ;
209+ const { stderr } = await run ( cmd ) ;
210+
211+ const reportExists = existsSync ( customPath ) ;
212+ strictEqual ( reportExists , true , 'Custom report file should be created when --save is provided' ) ;
213+
214+ doesNotMatch ( stderr , / - - o u t p u t r e q u i r e s - - s a v e t o w r i t e t h e r e p o r t / i, 'Should not warn when --save is provided' ) ;
215+
216+ const reportJson = JSON . parse ( readFileSync ( customPath , 'utf-8' ) ) ;
217+ ok ( Array . isArray ( reportJson . components ) , 'Report should have components array' ) ;
218+
219+ unlinkSync ( customPath ) ;
220+ rmSync ( customDir , { recursive : true , force : true } ) ;
221+ } ) ;
222+
184223 it ( 'outputs JSON only when using the --json flag' , async ( ) => {
185224 const cmd = `scan:eol --file ${ simpleSbom } --json` ;
186225 const { stdout } = await run ( cmd ) ;
@@ -236,6 +275,50 @@ describe('scan:eol e2e', () => {
236275 unlinkSync ( sbomPath ) ;
237276 } ) ;
238277
278+ it ( 'warns and skips saving when --sbomOutput is provided without --saveSbom' , async ( ) => {
279+ const customDir = path . join ( fixturesDir , 'sbom-outputs' ) ;
280+ const customPath = path . join ( customDir , 'custom-sbom.json' ) ;
281+ await mkdir ( customDir , { recursive : true } ) ;
282+
283+ const cmd = `scan:eol --dir ${ simpleDir } --sbomOutput ${ customPath } ` ;
284+ const { stderr } = await run ( cmd ) ;
285+
286+ const sbomExists = existsSync ( customPath ) ;
287+ strictEqual ( sbomExists , false , 'Custom SBOM file should not be created without --saveSbom' ) ;
288+
289+ match (
290+ stderr ,
291+ / - - s b o m O u t p u t r e q u i r e s - - s a v e S b o m t o w r i t e t h e S B O M / i,
292+ 'Should warn that --sbomOutput needs --saveSbom' ,
293+ ) ;
294+
295+ rmSync ( customDir , { recursive : true , force : true } ) ;
296+ } ) ;
297+
298+ it ( 'saves SBOM to a custom path when --sbomOutput is provided' , async ( ) => {
299+ const customDir = path . join ( fixturesDir , 'sbom-outputs' ) ;
300+ const customPath = path . join ( customDir , 'custom-sbom.json' ) ;
301+ await mkdir ( customDir , { recursive : true } ) ;
302+
303+ const cmd = `scan:eol --dir ${ simpleDir } --saveSbom --sbomOutput ${ customPath } ` ;
304+ const { stderr } = await run ( cmd ) ;
305+
306+ const sbomExists = existsSync ( customPath ) ;
307+ strictEqual ( sbomExists , true , 'Custom SBOM file should be created' ) ;
308+
309+ doesNotMatch (
310+ stderr ,
311+ / - - s b o m O u t p u t r e q u i r e s - - s a v e S b o m t o w r i t e t h e S B O M / i,
312+ 'Should not warn when --saveSbom is provided' ,
313+ ) ;
314+
315+ const sbomJson = JSON . parse ( readFileSync ( customPath , 'utf-8' ) ) ;
316+ strictEqual ( sbomJson . bomFormat , 'CycloneDX' , 'SBOM should be CycloneDX format' ) ;
317+
318+ unlinkSync ( customPath ) ;
319+ rmSync ( customDir , { recursive : true , force : true } ) ;
320+ } ) ;
321+
239322 it ( 'saves both report and SBOM when both --save and --saveSbom flags are used' , async ( ) => {
240323 const reportPath = path . join ( simpleDir , `${ filenamePrefix } .report.json` ) ;
241324 const sbomPath = path . join ( simpleDir , `${ filenamePrefix } .sbom.json` ) ;
@@ -342,6 +425,33 @@ describe('scan:eol e2e', () => {
342425 doesNotMatch ( stdout , / V i e w y o u r f u l l E O L r e p o r t / , 'Should not show web report text when hidden' ) ;
343426 match ( stdout , / T o s a v e y o u r d e t a i l e d J S O N r e p o r t , u s e t h e - - s a v e f l a g / , 'Should show save hint message' ) ;
344427 } ) ;
428+
429+ it ( 'omits save hint when --hideReportUrl is paired with custom outputs' , async ( ) => {
430+ const customDir = path . join ( fixturesDir , 'hide-report-output' ) ;
431+ const customPath = path . join ( customDir , 'custom-report.json' ) ;
432+ await mkdir ( customDir , { recursive : true } ) ;
433+ const cmd = `scan:eol --file ${ simpleSbom } --hideReportUrl --save --output ${ customPath } ` ;
434+ const { stdout, stderr } = await run ( cmd ) ;
435+
436+ doesNotMatch (
437+ stdout ,
438+ / T o s a v e y o u r d e t a i l e d J S O N r e p o r t , u s e t h e - - s a v e f l a g / ,
439+ 'Should not show save hint when custom outputs are provided' ,
440+ ) ;
441+
442+ doesNotMatch (
443+ stderr ,
444+ / W a r n i n g : - - o u t p u t r e q u i r e s - - s a v e t o w r i t e t h e r e p o r t / i,
445+ 'Should not warn when --save is provided' ,
446+ ) ;
447+
448+ strictEqual ( existsSync ( customPath ) , true , 'Custom report file should be created' ) ;
449+
450+ if ( existsSync ( customPath ) ) {
451+ unlinkSync ( customPath ) ;
452+ }
453+ rmSync ( customDir , { recursive : true , force : true } ) ;
454+ } ) ;
345455 } ) ;
346456
347457 describe ( 'privacy and transparency' , ( ) => {
@@ -415,18 +525,17 @@ describe('scan:eol e2e', () => {
415525 return output ;
416526 }
417527
418- function expectAny ( output : { stdout : string ; stderr : string ; error ?: Error } , patterns : RegExp [ ] , message : string ) {
419- const text = `${ output . stderr } \n${ output . stdout } \n${ output . error ?. message || '' } ` ;
420- const matched = patterns . some ( ( re ) => re . test ( text ) ) ;
421- strictEqual ( matched , true , message ) ;
528+ function combinedOutputText ( output : { stdout : string ; stderr : string ; error ?: { message ?: unknown } } ) {
529+ const errorText = typeof output ?. error ?. message === 'string' ? output . error . message : '' ;
530+ return `${ output . stderr } \n${ output . stdout } \n${ errorText } ` ;
422531 }
423532
424533 it ( 'fails when SBOM file does not exist' , async ( ) => {
425534 const missing = path . join ( fixturesDir , 'npm' , 'does-not-exist.json' ) ;
426535 const out = await runExpectFail ( `scan:eol --file ${ missing } ` ) ;
427- expectAny (
428- out ,
429- [ / S B O M f i l e n o t f o u n d : / i , / F a i l e d t o r e a d S B O M f i l e / i , / F a i l e d t o l o a d S B O M f i l e / i , / L o a d i n g S B O M f i l e / i ] ,
536+ match (
537+ combinedOutputText ( out ) ,
538+ / ( S B O M f i l e n o t f o u n d : | F a i l e d t o r e a d S B O M f i l e | F a i l e d t o l o a d S B O M f i l e | L o a d i n g S B O M f i l e ) / i ,
430539 'Should indicate missing SBOM file' ,
431540 ) ;
432541 } ) ;
@@ -436,9 +545,9 @@ describe('scan:eol e2e', () => {
436545 writeFileSync ( badFile , '{not-json' ) ;
437546 try {
438547 const out = await runExpectFail ( `scan:eol --file ${ badFile } ` ) ;
439- expectAny (
440- out ,
441- [ / F a i l e d t o r e a d S B O M f i l e / i , / F a i l e d t o l o a d S B O M f i l e / i , / L o a d i n g S B O M f i l e / i ] ,
548+ match (
549+ combinedOutputText ( out ) ,
550+ / ( F a i l e d t o r e a d S B O M f i l e | F a i l e d t o l o a d S B O M f i l e | L o a d i n g S B O M f i l e ) / i ,
442551 'Should indicate invalid SBOM' ,
443552 ) ;
444553 } finally {
@@ -451,9 +560,9 @@ describe('scan:eol e2e', () => {
451560 writeFileSync ( badFile , JSON . stringify ( { invalid : 'format' , notSpdx : true , notCdx : true } ) ) ;
452561 try {
453562 const out = await runExpectFail ( `scan:eol --file ${ badFile } ` ) ;
454- expectAny (
455- out ,
456- [ / F a i l e d t o r e a d S B O M f i l e / i , / I n v a l i d S B O M f i l e f o r m a t / i , / E x p e c t e d S P D X 2 \. 3 o r C y c l o n e D X f o r m a t . / i ] ,
563+ match (
564+ combinedOutputText ( out ) ,
565+ / ( F a i l e d t o r e a d S B O M f i l e | I n v a l i d S B O M f i l e f o r m a t | E x p e c t e d S P D X 2 \. 3 o r C y c l o n e D X f o r m a t \. ) / i ,
457566 'Should indicate invalid SBOM format' ,
458567 ) ;
459568 } finally {
@@ -464,18 +573,18 @@ describe('scan:eol e2e', () => {
464573 it ( 'fails when directory does not exist' , async ( ) => {
465574 const missingDir = path . join ( fixturesDir , 'npm' , 'no-such-dir' ) ;
466575 const out = await runExpectFail ( `scan:eol --dir ${ missingDir } ` ) ;
467- expectAny (
468- out ,
469- [ / D i r e c t o r y n o t f o u n d : / i , / F a i l e d t o s c a n d i r e c t o r y / i , / G e n e r a t i n g S B O M / i ] ,
576+ match (
577+ combinedOutputText ( out ) ,
578+ / ( D i r e c t o r y n o t f o u n d : | F a i l e d t o s c a n d i r e c t o r y | G e n e r a t i n g S B O M ) / i ,
470579 'Should indicate missing directory' ,
471580 ) ;
472581 } ) ;
473582
474583 it ( 'fails when provided path is not a directory' , async ( ) => {
475584 const out = await runExpectFail ( `scan:eol --dir ${ simpleSbom } ` ) ;
476- expectAny (
477- out ,
478- [ / P a t h i s n o t a d i r e c t o r y : / i , / F a i l e d t o s c a n d i r e c t o r y / i , / G e n e r a t i n g S B O M / i ] ,
585+ match (
586+ combinedOutputText ( out ) ,
587+ / ( P a t h i s n o t a d i r e c t o r y : | F a i l e d t o s c a n d i r e c t o r y | G e n e r a t i n g S B O M ) / i ,
479588 'Should indicate non-directory path' ,
480589 ) ;
481590 } ) ;
@@ -485,7 +594,11 @@ describe('scan:eol e2e', () => {
485594 fetchMock . restore ( ) ;
486595 fetchMock = new FetchMock ( ) . addGraphQL ( { eol : { createReport : { success : false , id : null , totalRecords : 0 } } } ) ;
487596 const out = await runExpectFail ( `scan:eol --file ${ simpleSbom } ` ) ;
488- expectAny ( out , [ / F a i l e d t o s u b m i t s c a n t o N E S / i, / S c a n n i n g f a i l e d / i] , 'Should indicate NES submission failure' ) ;
597+ match (
598+ combinedOutputText ( out ) ,
599+ / ( F a i l e d t o s u b m i t s c a n t o N E S | S c a n n i n g f a i l e d ) / i,
600+ 'Should indicate NES submission failure' ,
601+ ) ;
489602 } ) ;
490603
491604 it ( 'fails when NES returns GraphQL errors' , async ( ) => {
@@ -494,7 +607,27 @@ describe('scan:eol e2e', () => {
494607 { message : 'Internal server error' , path : [ 'eol' , 'createReport' ] } ,
495608 ] ) ;
496609 const out = await runExpectFail ( `scan:eol --file ${ simpleSbom } ` ) ;
497- expectAny ( out , [ / F a i l e d t o s u b m i t s c a n t o N E S / i, / S c a n n i n g f a i l e d / i] , 'Should indicate GraphQL errors from NES' ) ;
610+ match (
611+ combinedOutputText ( out ) ,
612+ / ( F a i l e d t o s u b m i t s c a n t o N E S | S c a n n i n g f a i l e d ) / i,
613+ 'Should indicate GraphQL errors from NES' ,
614+ ) ;
615+ } ) ;
616+
617+ it ( 'shows a helpful error when report output directory is invalid' , async ( ) => {
618+ const invalidPath = path . join ( fixturesDir , 'missing-dir' , 'custom-report.json' ) ;
619+ const out = await runExpectFail ( `scan:eol --dir ${ simpleDir } --save --output ${ invalidPath } ` ) ;
620+ match (
621+ combinedOutputText ( out ) ,
622+ / U n a b l e t o s a v e c u s t o m - r e p o r t \. j s o n / i,
623+ 'Should indicate report could not be saved' ,
624+ ) ;
625+ } ) ;
626+
627+ it ( 'shows a helpful error when SBOM output directory is invalid' , async ( ) => {
628+ const invalidPath = path . join ( fixturesDir , 'missing-dir' , 'custom-sbom.json' ) ;
629+ const out = await runExpectFail ( `scan:eol --dir ${ simpleDir } --saveSbom --sbomOutput ${ invalidPath } ` ) ;
630+ match ( combinedOutputText ( out ) , / U n a b l e t o s a v e c u s t o m - s b o m \. j s o n / i, 'Should indicate SBOM could not be saved' ) ;
498631 } ) ;
499632 } ) ;
500633} ) ;
0 commit comments