From 5030773013f6c2e3c11ba54089b0aa075385de11 Mon Sep 17 00:00:00 2001 From: Venancio Orozco <4390221+v3nant@users.noreply.github.com> Date: Tue, 21 Oct 2025 15:33:55 -0600 Subject: [PATCH 1/2] feat: adds file --output and --sbomOutput flags to specify custom file paths --- README.md | 8 +- e2e/scan/eol.test.ts | 143 +++++++++++++++++++++++++++++++++- src/commands/scan/eol.ts | 51 +++++++++--- src/hooks/finally/finally.ts | 8 +- src/service/file.svc.ts | 98 +++++++++++++++-------- test/service/file.svc.test.ts | 40 +++++++++- 6 files changed, 295 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 355a3f08..87a7bd74 100644 --- a/README.md +++ b/README.md @@ -112,14 +112,16 @@ Scan a given SBOM for EOL data ``` USAGE - $ hd scan eol [--json] [-f | -d ] [-s] [--saveSbom] [--saveTrimmedSbom] [--hideReportUrl] [--version] + $ hd scan eol [--json] [-f | -d ] [-s] [-o ] [--saveSbom] [--sbomOutput ] [--saveTrimmedSbom] [--hideReportUrl] [--version] FLAGS -d, --dir= [default: ] The directory to scan in order to scan for EOL -f, --file= The file path of an existing SBOM to scan for EOL (supports CycloneDX and SPDX 2.3 formats) -s, --save Save the generated report as herodevs.report.json in the scanned directory + -o, --output= Save the generated report to a custom path (requires --save, defaults to herodevs.report.json when not provided) --hideReportUrl Hide the generated web report URL for this scan --saveSbom Save the generated SBOM as herodevs.sbom.json in the scanned directory + --sbomOutput= Save the generated SBOM to a custom path (requires --saveSbom, defaults to herodevs.sbom.json when not provided) --saveTrimmedSbom Save the trimmed SBOM as herodevs.sbom-trimmed.json in the scanned directory --version Show CLI version. @@ -146,6 +148,10 @@ EXAMPLES $ hd scan eol --save --saveSbom + Save the report and SBOM to custom paths + + $ hd scan eol --dir . --save --saveSbom --output ./reports/my-report.json --sbomOutput ./reports/my-sbom.json + Output the report in JSON format (for APIs, CI, etc.) $ hd scan eol --json diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 6596004a..471ca016 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -1,7 +1,7 @@ import { doesNotThrow } from 'node:assert'; import { doesNotMatch, match, notStrictEqual, strictEqual } from 'node:assert/strict'; import { exec } from 'node:child_process'; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; @@ -21,6 +21,14 @@ const upToDateDir = path.resolve(fixturesDir, 'npm/up-to-date'); const upToDateSbom = path.join(fixturesDir, 'npm/up-to-date.sbom.json'); const noComponentsSbom = path.join(fixturesDir, 'npm/no-components.sbom.json'); +function expectContains(text: string, expected: string, message: string) { + strictEqual(text.toLowerCase().includes(expected.toLowerCase()), true, message); +} + +function expectNotContains(text: string, expected: string, message: string) { + strictEqual(text.toLowerCase().includes(expected.toLowerCase()), false, message); +} + function mockReport(components: DeepPartial[] = []) { return { eol: { @@ -181,6 +189,46 @@ describe('scan:eol e2e', () => { unlinkSync(reportPath); }); + it('warns and skips saving when --output is provided without --save', async () => { + const customDir = path.join(fixturesDir, 'outputs'); + const customPath = path.join(customDir, 'custom-report.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --output ${customPath}`; + const { stderr } = await run(cmd); + + const reportExists = existsSync(customPath); + strictEqual(reportExists, false, 'Custom report file should not be created without --save'); + + expectContains(stderr, '--output requires --save to write the report', 'Should warn that --output needs --save'); + + rmSync(customDir, { recursive: true, force: true }); + }); + + it('saves report to a custom path when --save and --output are provided', async () => { + const customDir = path.join(fixturesDir, 'outputs-save'); + const customPath = path.join(customDir, 'custom-report.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --save --output ${customPath}`; + const { stderr } = await run(cmd); + + const reportExists = existsSync(customPath); + strictEqual(reportExists, true, 'Custom report file should be created when --save is provided'); + + expectNotContains( + stderr, + '--output requires --save to write the report', + 'Should not warn when --save is provided', + ); + + const reportJson = JSON.parse(readFileSync(customPath, 'utf-8')); + strictEqual(Array.isArray(reportJson.components), true, 'Report should have components array'); + + unlinkSync(customPath); + rmSync(customDir, { recursive: true, force: true }); + }); + it('outputs JSON only when using the --json flag', async () => { const cmd = `scan:eol --file ${simpleSbom} --json`; const { stdout } = await run(cmd); @@ -236,6 +284,50 @@ describe('scan:eol e2e', () => { unlinkSync(sbomPath); }); + it('warns and skips saving when --sbomOutput is provided without --saveSbom', async () => { + const customDir = path.join(fixturesDir, 'sbom-outputs'); + const customPath = path.join(customDir, 'custom-sbom.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --sbomOutput ${customPath}`; + const { stderr } = await run(cmd); + + const sbomExists = existsSync(customPath); + strictEqual(sbomExists, false, 'Custom SBOM file should not be created without --saveSbom'); + + expectContains( + stderr, + '--sbomOutput requires --saveSbom to write the SBOM', + 'Should warn that --sbomOutput needs --saveSbom', + ); + + rmSync(customDir, { recursive: true, force: true }); + }); + + it('saves SBOM to a custom path when --sbomOutput is provided', async () => { + const customDir = path.join(fixturesDir, 'sbom-outputs'); + const customPath = path.join(customDir, 'custom-sbom.json'); + await mkdir(customDir, { recursive: true }); + + const cmd = `scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${customPath}`; + const { stderr } = await run(cmd); + + const sbomExists = existsSync(customPath); + strictEqual(sbomExists, true, 'Custom SBOM file should be created'); + + expectNotContains( + stderr, + '--sbomOutput requires --saveSbom to write the SBOM', + 'Should not warn when --saveSbom is provided', + ); + + const sbomJson = JSON.parse(readFileSync(customPath, 'utf-8')); + strictEqual(sbomJson.bomFormat, 'CycloneDX', 'SBOM should be CycloneDX format'); + + unlinkSync(customPath); + rmSync(customDir, { recursive: true, force: true }); + }); + it('saves both report and SBOM when both --save and --saveSbom flags are used', async () => { const reportPath = path.join(simpleDir, `${filenamePrefix}.report.json`); const sbomPath = path.join(simpleDir, `${filenamePrefix}.sbom.json`); @@ -342,6 +434,33 @@ describe('scan:eol e2e', () => { doesNotMatch(stdout, /View your full EOL report/, 'Should not show web report text when hidden'); match(stdout, /To save your detailed JSON report, use the --save flag/, 'Should show save hint message'); }); + + it('omits save hint when --hideReportUrl is paired with custom outputs', async () => { + const customDir = path.join(fixturesDir, 'hide-report-output'); + const customPath = path.join(customDir, 'custom-report.json'); + await mkdir(customDir, { recursive: true }); + const cmd = `scan:eol --file ${simpleSbom} --hideReportUrl --save --output ${customPath}`; + const { stdout, stderr } = await run(cmd); + + doesNotMatch( + stdout, + /To save your detailed JSON report, use the --save flag/, + 'Should not show save hint when custom outputs are provided', + ); + + expectNotContains( + stderr, + 'Warning: --output requires --save to write the report', + 'Should not warn when --save is provided', + ); + + strictEqual(existsSync(customPath), true, 'Custom report file should be created'); + + if (existsSync(customPath)) { + unlinkSync(customPath); + } + rmSync(customDir, { recursive: true, force: true }); + }); }); describe('privacy and transparency', () => { @@ -415,8 +534,14 @@ describe('scan:eol e2e', () => { return output; } - function expectAny(output: { stdout: string; stderr: string; error?: Error }, patterns: RegExp[], message: string) { - const text = `${output.stderr}\n${output.stdout}\n${output.error?.message || ''}`; + function expectAny( + output: { stdout: string; stderr: string; error?: { message?: unknown } }, + patterns: RegExp[], + message: string, + ) { + const errorText = + 'error' in output && output.error && typeof output.error.message === 'string' ? output.error.message : ''; + const text = `${output.stderr}\n${output.stdout}\n${errorText}`; const matched = patterns.some((re) => re.test(text)); strictEqual(matched, true, message); } @@ -496,5 +621,17 @@ describe('scan:eol e2e', () => { const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate GraphQL errors from NES'); }); + + it('shows a helpful error when report output directory is invalid', async () => { + const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-report.json'); + const out = await runExpectFail(`scan:eol --dir ${simpleDir} --save --output ${invalidPath}`); + expectAny(out, [/Unable to save custom-report\.json/i], 'Should indicate report could not be saved'); + }); + + it('shows a helpful error when SBOM output directory is invalid', async () => { + const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-sbom.json'); + const out = await runExpectFail(`scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${invalidPath}`); + expectAny(out, [/Unable to save custom-sbom\.json/i], 'Should indicate SBOM could not be saved'); + }); }); }); diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 03b3b656..f5048bd1 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -59,11 +59,19 @@ export default class ScanEol extends Command { default: false, description: `Save the generated report as ${filenamePrefix}.report.json in the scanned directory`, }), + output: Flags.string({ + char: 'o', + description: `Save the generated report to a custom path (defaults to ${filenamePrefix}.report.json when not provided)`, + }), saveSbom: Flags.boolean({ aliases: ['save-sbom'], default: false, description: `Save the generated SBOM as ${filenamePrefix}.sbom.json in the scanned directory`, }), + sbomOutput: Flags.string({ + aliases: ['sbom-output'], + description: `Save the generated SBOM to a custom path (defaults to ${filenamePrefix}.sbom.json when not provided)`, + }), saveTrimmedSbom: Flags.boolean({ aliases: ['save-trimmed-sbom'], default: false, @@ -97,8 +105,28 @@ export default class ScanEol extends Command { })); } - if (flags.saveSbom && !flags.file) { - const sbomPath = this.saveSbom(flags.dir, sbom); + let reportOutputPath = flags.output; + let sbomOutputPath = flags.sbomOutput; + let hasCustomReportSave = false; + let hasCustomSbomSave = false; + + if (flags.output && !flags.save) { + this.warn('--output requires --save to write the report. Run again with --save to create the file.'); + reportOutputPath = undefined; + } else if (flags.output && flags.save) { + hasCustomReportSave = true; + } + + if (flags.sbomOutput && !flags.saveSbom) { + this.warn('--sbomOutput requires --saveSbom to write the SBOM. Run again with --saveSbom to create the file.'); + sbomOutputPath = undefined; + } else if (flags.sbomOutput && flags.saveSbom) { + hasCustomSbomSave = true; + } + + const shouldSaveSbom = !flags.file && flags.saveSbom; + if (shouldSaveSbom) { + const sbomPath = this.saveSbom(flags.dir, sbom, sbomOutputPath); this.log(`SBOM saved to ${sbomPath}`); track('CLI SBOM Output Saved', (context) => ({ command: context.command, @@ -135,8 +163,9 @@ export default class ScanEol extends Command { web_report_hidden: flags.hideReportUrl, })); - if (flags.save) { - const reportPath = this.saveReport(scan, flags.dir); + const shouldSaveReport = flags.save; + if (shouldSaveReport) { + const reportPath = this.saveReport(scan, flags.dir, reportOutputPath); this.log(`Report saved to ${reportPath}`); track('CLI JSON Scan Output Saved', (context) => ({ command: context.command, @@ -146,7 +175,7 @@ export default class ScanEol extends Command { } if (!this.jsonEnabled()) { - this.displayResults(scan, flags.hideReportUrl); + this.displayResults(scan, flags.hideReportUrl, hasCustomReportSave || hasCustomSbomSave); } return scan; @@ -202,9 +231,9 @@ export default class ScanEol extends Command { } } - private saveReport(report: EolReport, dir: string): string { + private saveReport(report: EolReport, dir: string, outputPath?: string): string { try { - return saveReportToFile(dir, report); + return saveReportToFile(dir, report, outputPath); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -212,9 +241,9 @@ export default class ScanEol extends Command { } } - private saveSbom(dir: string, sbom: CdxBom): string { + private saveSbom(dir: string, sbom: CdxBom, outputPath?: string): string { try { - return saveSbomToFile(dir, sbom); + return saveSbomToFile(dir, sbom, outputPath); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -232,7 +261,7 @@ export default class ScanEol extends Command { } } - private displayResults(report: EolReport, hideReportUrl: boolean): void { + private displayResults(report: EolReport, hideReportUrl: boolean, hasCustomOutput: boolean): void { const lines = formatScanResults(report); for (const line of lines) { this.log(line); @@ -243,7 +272,7 @@ export default class ScanEol extends Command { for (const line of lines) { this.log(line); } - } else if (hideReportUrl) { + } else if (hideReportUrl && !hasCustomOutput) { const lines = formatReportSaveHint(); for (const line of lines) { this.log(line); diff --git a/src/hooks/finally/finally.ts b/src/hooks/finally/finally.ts index c769ac6a..db4fb6e1 100644 --- a/src/hooks/finally/finally.ts +++ b/src/hooks/finally/finally.ts @@ -4,10 +4,11 @@ import { track } from '../../service/analytics.svc.ts'; const hook: Hook<'finally'> = async (opts) => { const isHelpOrVersionCmd = opts.argv.includes('--help') || opts.argv.includes('--version'); + const hasError = Boolean(opts.error); let spinner: Ora | undefined; - if (!isHelpOrVersionCmd) { + if (!isHelpOrVersionCmd && !hasError) { spinner = ora().start('Cleaning up'); } @@ -16,8 +17,9 @@ const hook: Hook<'finally'> = async (opts) => { ended_at: new Date(), })).promise; - if (!isHelpOrVersionCmd) { - await event; + await event; + + if (!isHelpOrVersionCmd && !hasError) { spinner?.stop(); } }; diff --git a/src/service/file.svc.ts b/src/service/file.svc.ts index a257a5c3..f8878258 100644 --- a/src/service/file.svc.ts +++ b/src/service/file.svc.ts @@ -9,6 +9,62 @@ export interface FileError extends Error { code?: string; } +/** + * Computes an absolute output path using either a provided path or the base directory and default name. + */ +function resolveOutputPath( + baseDir: string, + defaultFilename: string, + customPath?: string, +): { fileName: string; fullPath: string } { + const output = customPath ? resolve(customPath) : resolve(join(baseDir, defaultFilename)); + return { fileName: path.basename(output), fullPath: output }; +} + +/** + * Ensures the directory for a target file path exists and is a directory before writing. + */ +function ensureOutputDirectory(fullPath: string, fileName: string): void { + const targetDir = path.dirname(fullPath); + + if (!fs.existsSync(targetDir)) { + throw new Error(`Unable to save ${fileName}`); + } + + const stats = fs.statSync(targetDir); + if (!stats.isDirectory()) { + throw new Error(`Unable to save ${fileName}`); + } +} + +/** + * Writes JSON to disk after validating directory constraints and formats the payload for readability. + */ +function writeJsonFile(fullPath: string, fileName: string, payload: unknown, failureLabel: string): string { + ensureOutputDirectory(fullPath, fileName); + + try { + fs.writeFileSync(fullPath, JSON.stringify(payload, null, 2)); + return fullPath; + } catch (error) { + const fileError = error as FileError; + + if (fileError.code === 'EACCES') { + throw new Error(`Permission denied. Unable to save ${fileName}`); + } + + if (fileError.code === 'ENOSPC') { + throw new Error(`No space left on device. Unable to save ${fileName}`); + } + + if (fileError.code === 'ENOENT' || fileError.code === 'ENOTDIR') { + throw new Error(`Unable to save ${fileName}`); + } + + throw new Error(`Failed to save ${failureLabel}: ${getErrorMessage(error)}`); + } +} + /** * Reads an SBOM from a file path and converts it to CycloneDX format * Supports both SPDX 2.3 and CycloneDX formats @@ -57,49 +113,23 @@ export function validateDirectory(dirPath: string): void { /** * Saves an SBOM to a file in the specified directory */ -export function saveSbomToFile(dir: string, sbom: CdxBom): string { - const outputPath = join(dir, `${filenamePrefix}.sbom.json`); - - try { - fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2)); - return outputPath; - } catch (error) { - throw new Error(`Failed to save SBOM: ${getErrorMessage(error)}`); - } +export function saveSbomToFile(dir: string, sbom: CdxBom, outputPath?: string): string { + const { fileName, fullPath } = resolveOutputPath(dir, `${filenamePrefix}.sbom.json`, outputPath); + return writeJsonFile(fullPath, fileName, sbom, fileName); } /** * Saves a trimmed SBOM to a file in the specified directory */ export function saveTrimmedSbomToFile(dir: string, sbom: CdxBom): string { - const outputPath = join(dir, `${filenamePrefix}.sbom-trimmed.json`); - - try { - fs.writeFileSync(outputPath, JSON.stringify(sbom, null, 2)); - return outputPath; - } catch (error) { - throw new Error(`Failed to save trimmed SBOM: ${getErrorMessage(error)}`); - } + const { fileName, fullPath } = resolveOutputPath(dir, `${filenamePrefix}.sbom-trimmed.json`); + return writeJsonFile(fullPath, fileName, sbom, fileName); } /** * Saves an EOL report to a file in the specified directory */ -export function saveReportToFile(dir: string, report: EolReport): string { - const reportPath = path.join(dir, `${filenamePrefix}.report.json`); - - try { - fs.writeFileSync(reportPath, JSON.stringify(report, null, 2)); - return reportPath; - } catch (error) { - const fileError = error as FileError; - - if (fileError.code === 'EACCES') { - throw new Error(`Permission denied. Unable to save report to ${filenamePrefix}.report.json`); - } - if (fileError.code === 'ENOSPC') { - throw new Error(`No space left on device. Unable to save report to ${filenamePrefix}.report.json`); - } - throw new Error(`Failed to save report: ${getErrorMessage(error)}`); - } +export function saveReportToFile(dir: string, report: EolReport, outputPath?: string): string { + const { fileName, fullPath } = resolveOutputPath(dir, `${filenamePrefix}.report.json`, outputPath); + return writeJsonFile(fullPath, fileName, report, fileName); } diff --git a/test/service/file.svc.test.ts b/test/service/file.svc.test.ts index 5dcdbf51..18a12ac8 100644 --- a/test/service/file.svc.test.ts +++ b/test/service/file.svc.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert'; import fs from 'node:fs'; -import { mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, describe, it } from 'node:test'; @@ -137,6 +137,25 @@ describe('file.svc', () => { assert.ok(outputPath.endsWith('herodevs.sbom.json')); assert.ok(outputPath.includes(tempDir)); }); + + it('should save SBOM to a custom path', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + const customDir = join(tempDir, 'nested'); + await mkdir(customDir); + + const customPath = join(customDir, 'custom-sbom.json'); + const outputPath = saveSbomToFile(tempDir, mockSbom, customPath); + + assert.strictEqual(outputPath, customPath); + assert.ok(fs.existsSync(customPath)); + }); + + it('should throw a descriptive error when the custom directory is missing', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + const missingPath = join(tempDir, 'missing', 'custom-sbom.json'); + + assert.throws(() => saveSbomToFile(tempDir, mockSbom, missingPath), /Unable to save custom-sbom\.json/); + }); }); describe('saveTrimmedSbomToFile', () => { @@ -181,5 +200,24 @@ describe('file.svc', () => { assert.ok(outputPath.endsWith('herodevs.report.json')); assert.ok(outputPath.includes(tempDir)); }); + + it('should save report to a custom path', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + const customDir = join(tempDir, 'nested'); + await mkdir(customDir); + + const customPath = join(customDir, 'my-report.json'); + const outputPath = saveReportToFile(tempDir, mockReport, customPath); + + assert.strictEqual(outputPath, customPath); + assert.ok(fs.existsSync(customPath)); + }); + + it('should throw a descriptive error when the custom directory is missing', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + const missingPath = join(tempDir, 'missing', 'my-report.json'); + + assert.throws(() => saveReportToFile(tempDir, mockReport, missingPath), /Unable to save my-report\.json/); + }); }); }); From f1de0b602933ef62dc6e9e5409d0cd63205a5e6a Mon Sep 17 00:00:00 2001 From: Venancio Orozco <4390221+v3nant@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:19:02 -0600 Subject: [PATCH 2/2] chore: fixing PR comments --- e2e/scan/eol.test.ts | 104 +++++++++++++------------- src/commands/scan/eol.ts | 22 ++---- src/hooks/finally/finally.ts | 4 +- src/service/file.svc.ts | 81 ++++++++++++-------- test/service/file.svc.test.ts | 135 +++++++++++++++++++++++----------- 5 files changed, 197 insertions(+), 149 deletions(-) diff --git a/e2e/scan/eol.test.ts b/e2e/scan/eol.test.ts index 471ca016..69c97e00 100644 --- a/e2e/scan/eol.test.ts +++ b/e2e/scan/eol.test.ts @@ -1,8 +1,10 @@ import { doesNotThrow } from 'node:assert'; -import { doesNotMatch, match, notStrictEqual, strictEqual } from 'node:assert/strict'; +import { doesNotMatch, match, notStrictEqual, ok, strictEqual } from 'node:assert/strict'; import { exec } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { existsSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'; import { mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { promisify } from 'node:util'; @@ -21,14 +23,6 @@ const upToDateDir = path.resolve(fixturesDir, 'npm/up-to-date'); const upToDateSbom = path.join(fixturesDir, 'npm/up-to-date.sbom.json'); const noComponentsSbom = path.join(fixturesDir, 'npm/no-components.sbom.json'); -function expectContains(text: string, expected: string, message: string) { - strictEqual(text.toLowerCase().includes(expected.toLowerCase()), true, message); -} - -function expectNotContains(text: string, expected: string, message: string) { - strictEqual(text.toLowerCase().includes(expected.toLowerCase()), false, message); -} - function mockReport(components: DeepPartial[] = []) { return { eol: { @@ -190,9 +184,8 @@ describe('scan:eol e2e', () => { }); it('warns and skips saving when --output is provided without --save', async () => { - const customDir = path.join(fixturesDir, 'outputs'); + const customDir = path.join(tmpdir(), 'scan-eol-report-output', randomUUID()); const customPath = path.join(customDir, 'custom-report.json'); - await mkdir(customDir, { recursive: true }); const cmd = `scan:eol --dir ${simpleDir} --output ${customPath}`; const { stderr } = await run(cmd); @@ -200,9 +193,11 @@ describe('scan:eol e2e', () => { const reportExists = existsSync(customPath); strictEqual(reportExists, false, 'Custom report file should not be created without --save'); - expectContains(stderr, '--output requires --save to write the report', 'Should warn that --output needs --save'); + match(stderr, /--output requires --save to write the report/i, 'Should warn that --output needs --save'); - rmSync(customDir, { recursive: true, force: true }); + if (existsSync(customDir)) { + rmSync(customDir, { recursive: true, force: true }); + } }); it('saves report to a custom path when --save and --output are provided', async () => { @@ -216,14 +211,10 @@ describe('scan:eol e2e', () => { const reportExists = existsSync(customPath); strictEqual(reportExists, true, 'Custom report file should be created when --save is provided'); - expectNotContains( - stderr, - '--output requires --save to write the report', - 'Should not warn when --save is provided', - ); + doesNotMatch(stderr, /--output requires --save to write the report/i, 'Should not warn when --save is provided'); const reportJson = JSON.parse(readFileSync(customPath, 'utf-8')); - strictEqual(Array.isArray(reportJson.components), true, 'Report should have components array'); + ok(Array.isArray(reportJson.components), 'Report should have components array'); unlinkSync(customPath); rmSync(customDir, { recursive: true, force: true }); @@ -295,9 +286,9 @@ describe('scan:eol e2e', () => { const sbomExists = existsSync(customPath); strictEqual(sbomExists, false, 'Custom SBOM file should not be created without --saveSbom'); - expectContains( + match( stderr, - '--sbomOutput requires --saveSbom to write the SBOM', + /--sbomOutput requires --saveSbom to write the SBOM/i, 'Should warn that --sbomOutput needs --saveSbom', ); @@ -315,9 +306,9 @@ describe('scan:eol e2e', () => { const sbomExists = existsSync(customPath); strictEqual(sbomExists, true, 'Custom SBOM file should be created'); - expectNotContains( + doesNotMatch( stderr, - '--sbomOutput requires --saveSbom to write the SBOM', + /--sbomOutput requires --saveSbom to write the SBOM/i, 'Should not warn when --saveSbom is provided', ); @@ -448,9 +439,9 @@ describe('scan:eol e2e', () => { 'Should not show save hint when custom outputs are provided', ); - expectNotContains( + doesNotMatch( stderr, - 'Warning: --output requires --save to write the report', + /Warning: --output requires --save to write the report/i, 'Should not warn when --save is provided', ); @@ -534,24 +525,17 @@ describe('scan:eol e2e', () => { return output; } - function expectAny( - output: { stdout: string; stderr: string; error?: { message?: unknown } }, - patterns: RegExp[], - message: string, - ) { - const errorText = - 'error' in output && output.error && typeof output.error.message === 'string' ? output.error.message : ''; - const text = `${output.stderr}\n${output.stdout}\n${errorText}`; - const matched = patterns.some((re) => re.test(text)); - strictEqual(matched, true, message); + function combinedOutputText(output: { stdout: string; stderr: string; error?: { message?: unknown } }) { + const errorText = typeof output?.error?.message === 'string' ? output.error.message : ''; + return `${output.stderr}\n${output.stdout}\n${errorText}`; } it('fails when SBOM file does not exist', async () => { const missing = path.join(fixturesDir, 'npm', 'does-not-exist.json'); const out = await runExpectFail(`scan:eol --file ${missing}`); - expectAny( - out, - [/SBOM file not found:/i, /Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i], + match( + combinedOutputText(out), + /(SBOM file not found:|Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i, 'Should indicate missing SBOM file', ); }); @@ -561,9 +545,9 @@ describe('scan:eol e2e', () => { writeFileSync(badFile, '{not-json'); try { const out = await runExpectFail(`scan:eol --file ${badFile}`); - expectAny( - out, - [/Failed to read SBOM file/i, /Failed to load SBOM file/i, /Loading SBOM file/i], + match( + combinedOutputText(out), + /(Failed to read SBOM file|Failed to load SBOM file|Loading SBOM file)/i, 'Should indicate invalid SBOM', ); } finally { @@ -576,9 +560,9 @@ describe('scan:eol e2e', () => { writeFileSync(badFile, JSON.stringify({ invalid: 'format', notSpdx: true, notCdx: true })); try { const out = await runExpectFail(`scan:eol --file ${badFile}`); - expectAny( - out, - [/Failed to read SBOM file/i, /Invalid SBOM file format/i, /Expected SPDX 2\.3 or CycloneDX format./i], + match( + combinedOutputText(out), + /(Failed to read SBOM file|Invalid SBOM file format|Expected SPDX 2\.3 or CycloneDX format\.)/i, 'Should indicate invalid SBOM format', ); } finally { @@ -589,18 +573,18 @@ describe('scan:eol e2e', () => { it('fails when directory does not exist', async () => { const missingDir = path.join(fixturesDir, 'npm', 'no-such-dir'); const out = await runExpectFail(`scan:eol --dir ${missingDir}`); - expectAny( - out, - [/Directory not found:/i, /Failed to scan directory/i, /Generating SBOM/i], + match( + combinedOutputText(out), + /(Directory not found:|Failed to scan directory|Generating SBOM)/i, 'Should indicate missing directory', ); }); it('fails when provided path is not a directory', async () => { const out = await runExpectFail(`scan:eol --dir ${simpleSbom}`); - expectAny( - out, - [/Path is not a directory:/i, /Failed to scan directory/i, /Generating SBOM/i], + match( + combinedOutputText(out), + /(Path is not a directory:|Failed to scan directory|Generating SBOM)/i, 'Should indicate non-directory path', ); }); @@ -610,7 +594,11 @@ describe('scan:eol e2e', () => { fetchMock.restore(); fetchMock = new FetchMock().addGraphQL({ eol: { createReport: { success: false, id: null, totalRecords: 0 } } }); const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); - expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate NES submission failure'); + match( + combinedOutputText(out), + /(Failed to submit scan to NES|Scanning failed)/i, + 'Should indicate NES submission failure', + ); }); it('fails when NES returns GraphQL errors', async () => { @@ -619,19 +607,27 @@ describe('scan:eol e2e', () => { { message: 'Internal server error', path: ['eol', 'createReport'] }, ]); const out = await runExpectFail(`scan:eol --file ${simpleSbom}`); - expectAny(out, [/Failed to submit scan to NES/i, /Scanning failed/i], 'Should indicate GraphQL errors from NES'); + match( + combinedOutputText(out), + /(Failed to submit scan to NES|Scanning failed)/i, + 'Should indicate GraphQL errors from NES', + ); }); it('shows a helpful error when report output directory is invalid', async () => { const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-report.json'); const out = await runExpectFail(`scan:eol --dir ${simpleDir} --save --output ${invalidPath}`); - expectAny(out, [/Unable to save custom-report\.json/i], 'Should indicate report could not be saved'); + match( + combinedOutputText(out), + /Unable to save custom-report\.json/i, + 'Should indicate report could not be saved', + ); }); it('shows a helpful error when SBOM output directory is invalid', async () => { const invalidPath = path.join(fixturesDir, 'missing-dir', 'custom-sbom.json'); const out = await runExpectFail(`scan:eol --dir ${simpleDir} --saveSbom --sbomOutput ${invalidPath}`); - expectAny(out, [/Unable to save custom-sbom\.json/i], 'Should indicate SBOM could not be saved'); + match(combinedOutputText(out), /Unable to save custom-sbom\.json/i, 'Should indicate SBOM could not be saved'); }); }); }); diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index f5048bd1..4d71f1c9 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -13,13 +13,7 @@ import { formatScanResults, formatWebReportUrl, } from '../../service/display.svc.ts'; -import { - readSbomFromFile, - saveReportToFile, - saveSbomToFile, - saveTrimmedSbomToFile, - validateDirectory, -} from '../../service/file.svc.ts'; +import { readSbomFromFile, saveArtifactToFile, validateDirectory } from '../../service/file.svc.ts'; import { getErrorMessage } from '../../service/log.svc.ts'; export default class ScanEol extends Command { @@ -107,21 +101,15 @@ export default class ScanEol extends Command { let reportOutputPath = flags.output; let sbomOutputPath = flags.sbomOutput; - let hasCustomReportSave = false; - let hasCustomSbomSave = false; if (flags.output && !flags.save) { this.warn('--output requires --save to write the report. Run again with --save to create the file.'); reportOutputPath = undefined; - } else if (flags.output && flags.save) { - hasCustomReportSave = true; } if (flags.sbomOutput && !flags.saveSbom) { this.warn('--sbomOutput requires --saveSbom to write the SBOM. Run again with --saveSbom to create the file.'); sbomOutputPath = undefined; - } else if (flags.sbomOutput && flags.saveSbom) { - hasCustomSbomSave = true; } const shouldSaveSbom = !flags.file && flags.saveSbom; @@ -175,7 +163,7 @@ export default class ScanEol extends Command { } if (!this.jsonEnabled()) { - this.displayResults(scan, flags.hideReportUrl, hasCustomReportSave || hasCustomSbomSave); + this.displayResults(scan, flags.hideReportUrl, Boolean(reportOutputPath || sbomOutputPath)); } return scan; @@ -233,7 +221,7 @@ export default class ScanEol extends Command { private saveReport(report: EolReport, dir: string, outputPath?: string): string { try { - return saveReportToFile(dir, report, outputPath); + return saveArtifactToFile(dir, { kind: 'report', payload: report, outputPath }); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -243,7 +231,7 @@ export default class ScanEol extends Command { private saveSbom(dir: string, sbom: CdxBom, outputPath?: string): string { try { - return saveSbomToFile(dir, sbom, outputPath); + return saveArtifactToFile(dir, { kind: 'sbom', payload: sbom, outputPath }); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); @@ -253,7 +241,7 @@ export default class ScanEol extends Command { private saveTrimmedSbom(dir: string, sbom: CdxBom): string { try { - return saveTrimmedSbomToFile(dir, sbom); + return saveArtifactToFile(dir, { kind: 'sbomTrimmed', payload: sbom }); } catch (error) { const errorMessage = getErrorMessage(error); track('CLI Error Encountered', () => ({ error: errorMessage })); diff --git a/src/hooks/finally/finally.ts b/src/hooks/finally/finally.ts index db4fb6e1..70d0576a 100644 --- a/src/hooks/finally/finally.ts +++ b/src/hooks/finally/finally.ts @@ -12,13 +12,11 @@ const hook: Hook<'finally'> = async (opts) => { spinner = ora().start('Cleaning up'); } - const event = track('CLI Session Ended', (context) => ({ + await track('CLI Session Ended', (context) => ({ cli_version: context.cli_version, ended_at: new Date(), })).promise; - await event; - if (!isHelpOrVersionCmd && !hasError) { spinner?.stop(); } diff --git a/src/service/file.svc.ts b/src/service/file.svc.ts index f8878258..4ab2d582 100644 --- a/src/service/file.svc.ts +++ b/src/service/file.svc.ts @@ -17,12 +17,27 @@ function resolveOutputPath( defaultFilename: string, customPath?: string, ): { fileName: string; fullPath: string } { - const output = customPath ? resolve(customPath) : resolve(join(baseDir, defaultFilename)); - return { fileName: path.basename(output), fullPath: output }; + const defaultOutput = resolve(join(baseDir, defaultFilename)); + + if (!customPath) { + return { fileName: defaultFilename, fullPath: defaultOutput }; + } + + const resolvedCustomPath = resolve(customPath); + let targetPath = resolvedCustomPath; + + const hasTrailingSeparator = /[\\/]$/.test(customPath); + const customIsDirectory = fs.existsSync(resolvedCustomPath) && fs.statSync(resolvedCustomPath).isDirectory(); + + if (hasTrailingSeparator || customIsDirectory) { + targetPath = join(resolvedCustomPath, defaultFilename); + } + + return { fileName: path.basename(targetPath), fullPath: targetPath }; } /** - * Ensures the directory for a target file path exists and is a directory before writing. + * Ensures the output directory for a given path exists, is a directory, and is writable. */ function ensureOutputDirectory(fullPath: string, fileName: string): void { const targetDir = path.dirname(fullPath); @@ -35,6 +50,12 @@ function ensureOutputDirectory(fullPath: string, fileName: string): void { if (!stats.isDirectory()) { throw new Error(`Unable to save ${fileName}`); } + + try { + fs.accessSync(targetDir, fs.constants.W_OK); + } catch { + throw new Error(`Unable to save ${fileName}`); + } } /** @@ -49,16 +70,14 @@ function writeJsonFile(fullPath: string, fileName: string, payload: unknown, fai } catch (error) { const fileError = error as FileError; - if (fileError.code === 'EACCES') { - throw new Error(`Permission denied. Unable to save ${fileName}`); - } - - if (fileError.code === 'ENOSPC') { - throw new Error(`No space left on device. Unable to save ${fileName}`); - } - - if (fileError.code === 'ENOENT' || fileError.code === 'ENOTDIR') { - throw new Error(`Unable to save ${fileName}`); + switch (fileError.code) { + case 'EACCES': + throw new Error(`Permission denied. Unable to save ${fileName}`); + case 'ENOSPC': + throw new Error(`No space left on device. Unable to save ${fileName}`); + case 'ENOENT': + case 'ENOTDIR': + throw new Error(`Unable to save ${fileName}`); } throw new Error(`Failed to save ${failureLabel}: ${getErrorMessage(error)}`); @@ -110,26 +129,26 @@ export function validateDirectory(dirPath: string): void { } } -/** - * Saves an SBOM to a file in the specified directory - */ -export function saveSbomToFile(dir: string, sbom: CdxBom, outputPath?: string): string { - const { fileName, fullPath } = resolveOutputPath(dir, `${filenamePrefix}.sbom.json`, outputPath); - return writeJsonFile(fullPath, fileName, sbom, fileName); -} +type SaveArtifactKind = 'sbom' | 'sbomTrimmed' | 'report'; -/** - * Saves a trimmed SBOM to a file in the specified directory - */ -export function saveTrimmedSbomToFile(dir: string, sbom: CdxBom): string { - const { fileName, fullPath } = resolveOutputPath(dir, `${filenamePrefix}.sbom-trimmed.json`); - return writeJsonFile(fullPath, fileName, sbom, fileName); -} +type SaveArtifactRequest = + | { kind: 'sbom'; payload: CdxBom; outputPath?: string } + | { kind: 'sbomTrimmed'; payload: CdxBom } + | { kind: 'report'; payload: EolReport; outputPath?: string }; + +const artifactFilenames: Record = { + sbom: `${filenamePrefix}.sbom.json`, + sbomTrimmed: `${filenamePrefix}.sbom-trimmed.json`, + report: `${filenamePrefix}.report.json`, +}; /** - * Saves an EOL report to a file in the specified directory + * Saves an SBOM, trimmed SBOM, or report to disk using the correct default filename. */ -export function saveReportToFile(dir: string, report: EolReport, outputPath?: string): string { - const { fileName, fullPath } = resolveOutputPath(dir, `${filenamePrefix}.report.json`, outputPath); - return writeJsonFile(fullPath, fileName, report, fileName); +export function saveArtifactToFile(dir: string, request: SaveArtifactRequest): string { + const defaultFilename = artifactFilenames[request.kind]; + const customOutputPath = 'outputPath' in request ? request.outputPath : undefined; + const { fileName, fullPath } = resolveOutputPath(dir, defaultFilename, customOutputPath); + + return writeJsonFile(fullPath, fileName, request.payload, fileName); } diff --git a/test/service/file.svc.test.ts b/test/service/file.svc.test.ts index 18a12ac8..eaf2aa5b 100644 --- a/test/service/file.svc.test.ts +++ b/test/service/file.svc.test.ts @@ -1,21 +1,26 @@ import assert from 'node:assert'; import fs from 'node:fs'; -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { after, describe, it } from 'node:test'; import type { CdxBom, EolReport, SPDX23 } from '@herodevs/eol-shared'; -import { - readSbomFromFile, - saveReportToFile, - saveSbomToFile, - saveTrimmedSbomToFile, - validateDirectory, -} from '../../src/service/file.svc.ts'; +import { readSbomFromFile, saveArtifactToFile, validateDirectory } from '../../src/service/file.svc.ts'; describe('file.svc', () => { let tempDir: string; + const createTempDir = () => { + const prefix = join(tmpdir(), 'file-svc-test-'); + + if (typeof fs.mkdtempDisposableSync === 'function') { + const { path: dirPath } = fs.mkdtempDisposableSync(prefix); + return dirPath; + } + + return fs.mkdtempSync(prefix); + }; + const mockSbom: CdxBom = { bomFormat: 'CycloneDX', specVersion: '1.6', @@ -54,7 +59,7 @@ describe('file.svc', () => { describe('readSbomFromFile', () => { it('should read and parse a valid CycloneDX SBOM file', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'test.json'); await writeFile(filePath, JSON.stringify(mockSbom)); @@ -63,7 +68,7 @@ describe('file.svc', () => { }); it('should read and convert a valid SPDX SBOM file to CycloneDX', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'spdx-test.json'); await writeFile(filePath, JSON.stringify(mockSpdxSbom)); @@ -79,7 +84,7 @@ describe('file.svc', () => { }); it('should throw error for invalid JSON', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'invalid.json'); await writeFile(filePath, 'invalid json'); @@ -87,7 +92,7 @@ describe('file.svc', () => { }); it('should throw error for invalid SBOM format (neither SPDX nor CycloneDX)', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'invalid-format.json'); await writeFile(filePath, JSON.stringify({ invalid: 'format' })); @@ -100,7 +105,7 @@ describe('file.svc', () => { describe('validateDirectory', () => { it('should not throw for valid directory', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); assert.doesNotThrow(() => validateDirectory(tempDir)); }); @@ -109,7 +114,7 @@ describe('file.svc', () => { }); it('should throw error for file instead of directory', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const filePath = join(tempDir, 'file.txt'); await writeFile(filePath, 'content'); @@ -117,11 +122,11 @@ describe('file.svc', () => { }); }); - describe('saveSbomToFile', () => { + describe('saveArtifactToFile', () => { it('should save SBOM to file successfully', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); - const outputPath = saveSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbom', payload: mockSbom }); assert.ok(fs.existsSync(outputPath)); const content = fs.readFileSync(outputPath, 'utf8'); @@ -129,40 +134,61 @@ describe('file.svc', () => { assert.deepStrictEqual(parsed, mockSbom); }); - it('should return the correct output path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should return the correct SBOM output path', async () => { + tempDir = createTempDir(); - const outputPath = saveSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbom', payload: mockSbom }); assert.ok(outputPath.endsWith('herodevs.sbom.json')); assert.ok(outputPath.includes(tempDir)); }); it('should save SBOM to a custom path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const customDir = join(tempDir, 'nested'); await mkdir(customDir); const customPath = join(customDir, 'custom-sbom.json'); - const outputPath = saveSbomToFile(tempDir, mockSbom, customPath); + const outputPath = saveArtifactToFile(tempDir, { + kind: 'sbom', + payload: mockSbom, + outputPath: customPath, + }); assert.strictEqual(outputPath, customPath); assert.ok(fs.existsSync(customPath)); }); - it('should throw a descriptive error when the custom directory is missing', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should throw a descriptive error when the custom directory is missing for SBOM', async () => { + tempDir = createTempDir(); const missingPath = join(tempDir, 'missing', 'custom-sbom.json'); - assert.throws(() => saveSbomToFile(tempDir, mockSbom, missingPath), /Unable to save custom-sbom\.json/); + assert.throws( + () => saveArtifactToFile(tempDir, { kind: 'sbom', payload: mockSbom, outputPath: missingPath }), + /Unable to save custom-sbom\.json/, + ); + }); + + it('should default to SBOM filename when directory path is provided', async () => { + tempDir = createTempDir(); + const customDir = join(tempDir, 'nested'); + await mkdir(customDir); + + const outputPath = saveArtifactToFile(tempDir, { + kind: 'sbom', + payload: mockSbom, + outputPath: customDir, + }); + + const expectedPath = join(customDir, 'herodevs.sbom.json'); + assert.strictEqual(outputPath, expectedPath); + assert.ok(fs.existsSync(expectedPath)); }); - }); - describe('saveTrimmedSbomToFile', () => { it('should save trimmed SBOM to file successfully', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); - const outputPath = saveTrimmedSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbomTrimmed', payload: mockSbom }); assert.ok(fs.existsSync(outputPath)); const content = fs.readFileSync(outputPath, 'utf8'); @@ -170,21 +196,19 @@ describe('file.svc', () => { assert.deepStrictEqual(parsed, mockSbom); }); - it('should return the correct output path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should return the correct trimmed SBOM output path', async () => { + tempDir = createTempDir(); - const outputPath = saveTrimmedSbomToFile(tempDir, mockSbom); + const outputPath = saveArtifactToFile(tempDir, { kind: 'sbomTrimmed', payload: mockSbom }); assert.ok(outputPath.endsWith('herodevs.sbom-trimmed.json')); assert.ok(outputPath.includes(tempDir)); }); - }); - describe('saveReportToFile', () => { it('should save report to file successfully', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); - const outputPath = saveReportToFile(tempDir, mockReport); + const outputPath = saveArtifactToFile(tempDir, { kind: 'report', payload: mockReport }); assert.ok(fs.existsSync(outputPath)); const content = fs.readFileSync(outputPath, 'utf8'); @@ -192,32 +216,55 @@ describe('file.svc', () => { assert.deepStrictEqual(parsed, mockReport); }); - it('should return the correct output path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should return the correct report output path', async () => { + tempDir = createTempDir(); - const outputPath = saveReportToFile(tempDir, mockReport); + const outputPath = saveArtifactToFile(tempDir, { kind: 'report', payload: mockReport }); assert.ok(outputPath.endsWith('herodevs.report.json')); assert.ok(outputPath.includes(tempDir)); }); it('should save report to a custom path', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + tempDir = createTempDir(); const customDir = join(tempDir, 'nested'); await mkdir(customDir); const customPath = join(customDir, 'my-report.json'); - const outputPath = saveReportToFile(tempDir, mockReport, customPath); + const outputPath = saveArtifactToFile(tempDir, { + kind: 'report', + payload: mockReport, + outputPath: customPath, + }); assert.strictEqual(outputPath, customPath); assert.ok(fs.existsSync(customPath)); }); - it('should throw a descriptive error when the custom directory is missing', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'file-svc-test-')); + it('should throw a descriptive error when the custom directory is missing for report', async () => { + tempDir = createTempDir(); const missingPath = join(tempDir, 'missing', 'my-report.json'); - assert.throws(() => saveReportToFile(tempDir, mockReport, missingPath), /Unable to save my-report\.json/); + assert.throws( + () => saveArtifactToFile(tempDir, { kind: 'report', payload: mockReport, outputPath: missingPath }), + /Unable to save my-report\.json/, + ); + }); + + it('should default to report filename when directory path is provided', async () => { + tempDir = createTempDir(); + const customDir = join(tempDir, 'reports'); + await mkdir(customDir); + + const outputPath = saveArtifactToFile(tempDir, { + kind: 'report', + payload: mockReport, + outputPath: customDir, + }); + + const expectedPath = join(customDir, 'herodevs.report.json'); + assert.strictEqual(outputPath, expectedPath); + assert.ok(fs.existsSync(expectedPath)); }); }); });