diff --git a/.envrc.example b/.envrc.example index b3e446a..f449742 100644 --- a/.envrc.example +++ b/.envrc.example @@ -2,3 +2,4 @@ export GRAPHQL_HOST='https://api.nes.herodevs.com'; export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports'; +export ANALYTICS_URL='https://eol-api.herodevs.com/track'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 24a89e1..f98a82b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.0-beta.4", "license": "MIT", "dependencies": { + "@amplitude/analytics-node": "^1.5.0", "@apollo/client": "^3.13.8", "@cyclonedx/cdxgen": "^11.4.3", "@herodevs/eol-shared": "github:herodevs/eol-shared#v0.1.4", @@ -16,6 +17,7 @@ "@oclif/plugin-help": "^6.2.29", "@oclif/plugin-update": "^4.6.45", "graphql": "^16.11.0", + "node-machine-id": "^1.1.12", "ora": "^8.2.0", "packageurl-js": "^2.0.1", "terminal-link": "^4.0.0", @@ -56,6 +58,32 @@ "node": ">=14.13.1" } }, + "node_modules/@amplitude/analytics-connector": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.6.4.tgz", + "integrity": "sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==", + "license": "MIT" + }, + "node_modules/@amplitude/analytics-core": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.20.0.tgz", + "integrity": "sha512-4SYLjrzpF7J905rKUVwtyoFyF3rFZJJe00RX5hTa4w7VpeLOVc0gW7txuJuyPn+pLPAQv612GQVhkfj1+DMr/w==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-connector": "^1.6.4", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-node": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-node/-/analytics-node-1.5.0.tgz", + "integrity": "sha512-sRx2H0l3okHj/szb9+Vc1Horx0fsWN034ohWfcPNVO4JylLUrZfS0wfFCAZVgJGTWPiFwSZZf/fdIuIDT/fjEg==", + "license": "MIT", + "dependencies": { + "@amplitude/analytics-core": "^2.20.0", + "tslib": "^2.4.1" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.9.3", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", @@ -9975,6 +10003,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-machine-id": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", + "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", + "license": "MIT" + }, "node_modules/node-stream-zip": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", diff --git a/package.json b/package.json index 20bd443..f5262a3 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepack": "oclif manifest && oclif readme", "pretest": "npm run lint && npm run typecheck", "readme": "npm run ci:fix && npm run build && npm exec oclif readme", - "test": "globstar -- node --import tsx --test \"test/**/*.test.ts\"", + "test": "globstar -- node --import tsx --test --experimental-test-module-mocks \"test/**/*.test.ts\"", "test:e2e": "globstar -- node --import tsx --test \"e2e/**/*.test.ts\"", "typecheck": "tsc --noEmit" }, @@ -36,6 +36,7 @@ "herodevs cli" ], "dependencies": { + "@amplitude/analytics-node": "^1.5.0", "@apollo/client": "^3.13.8", "@cyclonedx/cdxgen": "^11.4.3", "@herodevs/eol-shared": "github:herodevs/eol-shared#v0.1.4", @@ -43,6 +44,7 @@ "@oclif/plugin-help": "^6.2.29", "@oclif/plugin-update": "^4.6.45", "graphql": "^16.11.0", + "node-machine-id": "^1.1.12", "ora": "^8.2.0", "packageurl-js": "^2.0.1", "terminal-link": "^4.0.0", @@ -84,7 +86,8 @@ ], "hooks": { "init": "./dist/hooks/npm-update-notifier.js", - "prerun": "./dist/hooks/prerun.js" + "prerun": "./dist/hooks/prerun.js", + "finally": "./dist/hooks/finally.js" }, "topicSeparator": " ", "macos": { diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 3b7d5be..13d7069 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -4,8 +4,9 @@ import { Command, Flags } from '@oclif/core'; import ora from 'ora'; import { submitScan } from '../../api/nes.client.ts'; import { config, filenamePrefix } from '../../config/constants.ts'; +import { track } from '../../service/analytics.svc.ts'; import { createSbom } from '../../service/cdx.svc.ts'; -import { formatScanResults, formatWebReportUrl } from '../../service/display.svc.ts'; +import { countComponentsByStatus, formatScanResults, formatWebReportUrl } from '../../service/display.svc.ts'; import { readSbomFromFile, saveReportToFile, saveSbomToFile, validateDirectory } from '../../service/file.svc.ts'; import { getErrorMessage } from '../../service/log.svc.ts'; @@ -56,23 +57,72 @@ export default class ScanEol extends Command { public async run(): Promise { const { flags } = await this.parse(ScanEol); + track('CLI EOL Scan Started', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_location: flags.dir, + })); + + const sbomStartTime = performance.now(); const sbom = await this.loadSbom(); + const sbomEndTime = performance.now(); + + if (!flags.file) { + track('CLI SBOM Generated', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_location: flags.dir, + sbom_generation_time: (sbomEndTime - sbomStartTime) / 1000, + })); + } if (!sbom.components?.length) { + track('CLI EOL Scan Ended, No Components Found', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_location: flags.dir, + })); this.log('No components found in scan. Report not generated.'); return; } + const scanStartTime = performance.now(); const scan = await this.scanSbom(sbom); + const scanEndTime = performance.now(); + + const componentCounts = countComponentsByStatus(scan); + track('CLI EOL Scan Completed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + eol_true_count: componentCounts.EOL, + eol_unknown_count: componentCounts.UNKNOWN, + nes_available_count: componentCounts.NES_AVAILABLE, + number_of_packages: componentCounts.TOTAL, + sbom_created: !flags.file, + scan_location: flags.dir, + scan_load_time: (scanEndTime - scanStartTime) / 1000, + scanned_ecosystems: componentCounts.ECOSYSTEMS, + web_report_link: scan.id ? `${config.eolReportUrl}/${scan.id}` : undefined, + })); if (flags.save) { const reportPath = this.saveReport(scan, flags.dir); this.log(`Report saved to ${reportPath}`); + track('CLI JSON Scan Output Saved', (context) => ({ + command: context.command, + command_flags: context.command_flags, + report_output_path: reportPath, + })); } if (flags.saveSbom && !flags.file) { const sbomPath = this.saveSbom(flags.dir, sbom); this.log(`SBOM saved to ${sbomPath}`); + track('CLI SBOM Output Saved', (context) => ({ + command: context.command, + command_flags: context.command_flags, + sbom_output_path: sbomPath, + })); } if (!this.jsonEnabled()) { @@ -107,7 +157,14 @@ export default class ScanEol extends Command { return scan; } catch (error) { spinner.fail('Scanning failed'); - this.error(`Failed to submit scan to NES. ${getErrorMessage(error)}`); + const errorMessage = getErrorMessage(error); + track('CLI EOL Scan Failed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_location: context.scan_location, + scan_failure_reason: errorMessage, + })); + this.error(`Failed to submit scan to NES. ${errorMessage}`); } } @@ -115,7 +172,9 @@ export default class ScanEol extends Command { try { return saveReportToFile(dir, report); } catch (error) { - this.error(getErrorMessage(error)); + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(errorMessage); } } @@ -123,7 +182,9 @@ export default class ScanEol extends Command { try { return saveSbomToFile(dir, sbom); } catch (error) { - this.error(getErrorMessage(error)); + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(errorMessage); } } @@ -154,7 +215,9 @@ export default class ScanEol extends Command { } return sbom; } catch (error) { - this.error(`Failed to scan directory: ${getErrorMessage(error)}`); + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(`Failed to scan directory: ${errorMessage}`); } } @@ -162,7 +225,9 @@ export default class ScanEol extends Command { try { return readSbomFromFile(filePath); } catch (error) { - this.error(getErrorMessage(error)); + const errorMessage = getErrorMessage(error); + track('CLI Error Encountered', () => ({ error: errorMessage })); + this.error(errorMessage); } } } diff --git a/src/config/constants.ts b/src/config/constants.ts index 39afac7..b7259ff 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,11 +1,13 @@ export const EOL_REPORT_URL = 'https://eol-report-card.apps.herodevs.com/reports'; export const GRAPHQL_HOST = 'https://api.nes.herodevs.com'; export const GRAPHQL_PATH = '/graphql'; +export const ANALYTICS_URL = 'https://eol-api.herodevs.com/track'; export const config = { eolReportUrl: process.env.EOL_REPORT_URL || EOL_REPORT_URL, graphqlHost: process.env.GRAPHQL_HOST || GRAPHQL_HOST, graphqlPath: process.env.GRAPHQL_PATH || GRAPHQL_PATH, + analyticsUrl: process.env.ANALYTICS_URL || ANALYTICS_URL, showVulnCount: true, }; diff --git a/src/hooks/finally.ts b/src/hooks/finally.ts new file mode 100644 index 0000000..546c8f9 --- /dev/null +++ b/src/hooks/finally.ts @@ -0,0 +1,19 @@ +import type { Hook } from '@oclif/core'; +import ora from 'ora'; +import { track } from '../service/analytics.svc.ts'; + +const hook: Hook<'finally'> = async (opts) => { + const spinner = ora().start('Cleaning up'); + const event = track('CLI Session Ended', (context) => ({ + cli_version: context.cli_version, + ended_at: new Date(), + })).promise; + + if (!opts.argv.includes('--help')) { + await event; + } + + spinner.stop(); +}; + +export default hook; diff --git a/src/hooks/prerun.ts b/src/hooks/prerun.ts index f6d90b2..50b9c72 100644 --- a/src/hooks/prerun.ts +++ b/src/hooks/prerun.ts @@ -1,7 +1,20 @@ +import { parseArgs } from 'node:util'; import type { Hook } from '@oclif/core'; import debug from 'debug'; +import { initializeAnalytics, track } from '../service/analytics.svc.ts'; const hook: Hook<'prerun'> = async (opts) => { + const args = parseArgs({ allowPositionals: true, strict: false }); + initializeAnalytics(); + track('CLI Command Submitted', (context) => ({ + command: args.positionals.join(' ').trim(), + command_flags: Object.entries(args.values).flat().join(' '), + app_used: context.app_used, + ci_provider: context.ci_provider, + cli_version: context.cli_version, + started_at: context.started_at, + })); + // If JSON flag is enabled, silence debug logging if (opts.Command.prototype.jsonEnabled()) { debug.disable(); diff --git a/src/service/analytics.svc.ts b/src/service/analytics.svc.ts new file mode 100644 index 0000000..6baa806 --- /dev/null +++ b/src/service/analytics.svc.ts @@ -0,0 +1,119 @@ +import os from 'node:os'; +import { Identify, Types, track as _track, identify, init, setOptOut } from '@amplitude/analytics-node'; +import NodeMachineId from 'node-machine-id'; +import { config } from '../config/constants.ts'; + +const device_id = NodeMachineId.machineIdSync(true); +const started_at = new Date(); +const session_id = started_at.getTime(); + +interface AnalyticsContext { + // Session & Identity + locale?: string; + os_platform?: string; + os_release?: string; + started_at?: Date; + ended_at?: Date; + + // CLI Context + app_used?: string; + ci_provider?: string; + cli_version?: string; + command?: string; + command_flags?: string; + error?: string; + + // Scan Results + scan_location?: string; + eol_true_count?: number; + eol_unknown_count?: number; + nes_available_count?: number; + nes_remediation_count?: number; + number_of_packages?: number; + sbom_created?: boolean; + scan_load_time?: number; + scanned_ecosystems?: string[]; + scan_failure_reason?: string; + web_report_link?: string; +} + +const defaultAnalyticsContext: AnalyticsContext = { + locale: Intl.DateTimeFormat().resolvedOptions().locale, + os_platform: os.platform(), + os_release: os.release(), + cli_version: process.env.npm_package_version ?? 'unknown', + ci_provider: getCIProvider(), + app_used: getTerminal(), + started_at, +}; + +let analyticsContext: AnalyticsContext = defaultAnalyticsContext; + +export function initializeAnalytics() { + init('0', { + flushQueueSize: 2, + flushIntervalMillis: 250, + logLevel: Types.LogLevel.None, + serverUrl: config.analyticsUrl, + }); + setOptOut(process.env.TRACKING_OPT_OUT === 'true'); + identify(new Identify(), { + device_id, + platform: analyticsContext.os_platform, + os_name: getOSName(analyticsContext.os_platform ?? ''), + os_version: analyticsContext.os_release, + session_id, + app_version: analyticsContext.cli_version, + }); +} + +export function track(event: string, getProperties?: (context: AnalyticsContext) => Partial) { + const localContext = getProperties?.(analyticsContext); + if (localContext) { + analyticsContext = { ...analyticsContext, ...localContext }; + } + return _track(event, localContext, { device_id, session_id }); +} + +function getCIProvider(env = process.env): string | undefined { + if (env.GITHUB_ACTIONS) return 'github'; + if (env.GITLAB_CI) return 'gitlab'; + if (env.CIRCLECI) return 'circleci'; + if (env.TF_BUILD) return 'azure'; + if (env.BITBUCKET_COMMIT || env.BITBUCKET_BUILD_NUMBER) return 'bitbucket'; + if (env.JENKINS_URL) return 'jenkins'; + if (env.BUILDKITE) return 'buildkite'; + if (env.TRAVIS) return 'travis'; + if (env.TEAMCITY_VERSION) return 'teamcity'; + if (env.CODEBUILD_BUILD_ID) return 'codebuild'; + if (env.CI) return 'unknown_ci'; + return undefined; +} + +function getTerminal(env = process.env): string { + if (env.TERM_PROGRAM === 'vscode' || env.VSCODE_PID) return 'vscode'; + if (env.TERM_PROGRAM === 'iTerm.app' || env.ITERM_SESSION_ID) return 'iterm'; + if (env.TERM_PROGRAM === 'Apple_Terminal') return 'apple_terminal'; + if (env.TERM_PROGRAM === 'WarpTerminal' || env.WARP_IS_LOCAL_SHELL_SESSION) return 'warp'; + if (env.TERM_PROGRAM === 'ghostty' || env.GHOSTTY_RESOURCES_DIR || env.GHOSTTY_CONFIG_DIR) return 'ghostty'; + if (env.TERM_PROGRAM === 'WezTerm' || env.WEZTERM_EXECUTABLE || env.WEZTERM_PANE) return 'wezterm'; + if (env.TERM === 'alacritty' || env.ALACRITTY_LOG || env.ALACRITTY_SOCKET) return 'alacritty'; + if (env.TERM === 'xterm-kitty' || env.KITTY_WINDOW_ID) return 'kitty'; + if (env.WT_SESSION || env.WT_PROFILE_ID) return 'windows_terminal'; + if (env.ConEmuPID || env.ConEmuDir || env.CONEMU_BUILD) return 'conemu'; + if (env.TERM_PROGRAM === 'mintty' || env.MINTTY_SHORTCUT) return 'mintty'; + if (env.TILIX_ID) return 'tilix'; + if (env.GNOME_TERMINAL_SCREEN || env.GNOME_TERMINAL_SERVICE || env.VTE_VERSION) return 'gnome'; + if (env.KONSOLE_VERSION) return 'konsole'; + if (env.TERM_PROGRAM === 'Hyper') return 'hyper'; + return 'unknown_terminal'; +} + +function getOSName(platform: string): string { + if (platform === 'darwin') return 'macOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'linux') return 'Linux'; + if (platform === 'android') return 'Android'; + if (platform === 'ios') return 'iOS'; + return platform; +} diff --git a/src/service/display.svc.ts b/src/service/display.svc.ts index 05a8854..78cc0fa 100644 --- a/src/service/display.svc.ts +++ b/src/service/display.svc.ts @@ -20,18 +20,28 @@ export const getStatusRowText: Record string> EOL_UPCOMING: (text: string) => ux.colorize(STATUS_COLORS.EOL_UPCOMING, `! ${text}`), }; +export type ComponentCounts = Record & { + NES_AVAILABLE: number; + TOTAL: number; + ECOSYSTEMS: string[]; +}; + /** * Counts components by their status, including NES remediation availability */ -export function countComponentsByStatus(report: EolReport): Record { - const grouped: Record = { +export function countComponentsByStatus(report: EolReport): ComponentCounts { + const grouped: ComponentCounts = { UNKNOWN: 0, OK: 0, EOL_UPCOMING: 0, EOL: 0, NES_AVAILABLE: 0, + ECOSYSTEMS: [], + TOTAL: report.components.length, }; + const ecosystems = new Set(); + for (const component of report.components) { const status = deriveComponentStatus(component.metadata); grouped[status]++; @@ -39,8 +49,14 @@ export function countComponentsByStatus(report: EolReport): Record { + const mockAmplitude = { + init: sinon.spy(), + setOptOut: sinon.spy(), + identify: sinon.spy(), + track: sinon.spy(), + Identify: sinon.stub().returns({}), + Types: { LogLevel: { None: 0 } }, + }; + const mockNodeMachineId = { machineIdSync: sinon.stub().returns('test-machine-id') }; + let originalEnv: typeof process.env; + + function setupModule(t: TestContext) { + t.mock.module('@amplitude/analytics-node', { namedExports: mockAmplitude }); + t.mock.module('node-machine-id', { defaultExport: mockNodeMachineId }); + t.mock.module('../../src/config/constants.ts', { + namedExports: { config: { analyticsUrl: 'https://test-analytics.com' } }, + }); + + return import(import.meta.resolve(`../../src/service/analytics.svc.ts?${Math.random().toFixed(3)}`)); + } + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + mockAmplitude.init.resetHistory(); + mockAmplitude.setOptOut.resetHistory(); + mockAmplitude.identify.resetHistory(); + mockAmplitude.track.resetHistory(); + mockNodeMachineId.machineIdSync.resetHistory(); + }); + + describe('initializeAnalytics', () => { + it('should call amplitude init with correct parameters', async (t) => { + const mod = await setupModule(t); + mod.initializeAnalytics(); + + assert(mockAmplitude.init.calledOnce); + const initCall = mockAmplitude.init.getCall(0); + assert.strictEqual(initCall.args[0], '0'); + assert.deepStrictEqual(initCall.args[1], { + flushQueueSize: 2, + flushIntervalMillis: 250, + logLevel: 0, + serverUrl: 'https://test-analytics.com', + }); + }); + + it('should call setOptOut with true when TRACKING_OPT_OUT is true', async (t) => { + process.env.TRACKING_OPT_OUT = 'true'; + + const mod = await setupModule(t); + mod.initializeAnalytics(); + + assert(mockAmplitude.setOptOut.calledOnce); + assert.strictEqual(mockAmplitude.setOptOut.getCall(0).args[0], true); + }); + + it('should call setOptOut with false when TRACKING_OPT_OUT is not true', async (t) => { + process.env.TRACKING_OPT_OUT = 'false'; + + const mod = await setupModule(t); + mod.initializeAnalytics(); + + assert(mockAmplitude.setOptOut.calledOnce); + assert.strictEqual(mockAmplitude.setOptOut.getCall(0).args[0], false); + }); + + it('should call identify with correct user properties', async (t) => { + const mod = await setupModule(t); + mod.initializeAnalytics(); + + assert(mockAmplitude.identify.calledOnce); + const identifyCall = mockAmplitude.identify.getCall(0); + + const userProperties = identifyCall.args[1]; + assert.strictEqual(userProperties.device_id, 'test-machine-id'); + assert(typeof userProperties.session_id === 'number'); + assert(typeof userProperties.platform === 'string'); + assert(typeof userProperties.os_name === 'string'); + assert(typeof userProperties.os_version === 'string'); + assert(typeof userProperties.app_version === 'string'); + }); + + it('should handle case when npm_package_version is undefined', async (t) => { + process.env.npm_package_version = undefined; + + const mod = await setupModule(t); + mod.initializeAnalytics(); + + const identifyCall = mockAmplitude.identify.getCall(0); + const userProperties = identifyCall.args[1]; + assert.strictEqual(userProperties.app_version, 'unknown'); + }); + }); + + describe('track', () => { + it('should call amplitude track with event name and no properties when getProperties is undefined', async (t) => { + const mod = await setupModule(t); + mod.track('test-event'); + + assert(mockAmplitude.track.calledOnce); + const trackCall = mockAmplitude.track.getCall(0); + assert.strictEqual(trackCall.args[0], 'test-event'); + assert.strictEqual(trackCall.args[1], undefined); + assert(typeof trackCall.args[2].device_id === 'string'); + assert(typeof trackCall.args[2].session_id === 'number'); + }); + + it('should call amplitude track with event name and properties when getProperties returns data', async (t) => { + const mod = await setupModule(t); + const testProperties = { scan_location: '/test/path', eol_true_count: 5 }; + const getProperties = sinon.stub().returns(testProperties); + + mod.track('test-event', getProperties); + + assert(mockAmplitude.track.calledOnce); + const trackCall = mockAmplitude.track.getCall(0); + assert.strictEqual(trackCall.args[0], 'test-event'); + assert.deepStrictEqual(trackCall.args[1], testProperties); + assert(typeof trackCall.args[2].device_id === 'string'); + assert(typeof trackCall.args[2].session_id === 'number'); + }); + + it('should merge properties into analyticsContext when getProperties returns data', async (t) => { + const mod = await setupModule(t); + const firstProperties = { scan_location: '/test/path1', eol_true_count: 3 }; + const secondProperties = { scan_location: '/test/path2', eol_unknown_count: 2 }; + + mod.track('test-event-1', () => firstProperties); + + const getSecondProperties = sinon.stub().callsFake((context) => { + assert.strictEqual(context.scan_location, '/test/path1'); + assert.strictEqual(context.eol_true_count, 3); + return secondProperties; + }); + + mod.track('test-event-2', getSecondProperties); + + assert(getSecondProperties.calledOnce); + assert(mockAmplitude.track.calledTwice); + }); + + it('should preserve existing analyticsContext when getProperties returns undefined', async (t) => { + const mod = await setupModule(t); + const initialProperties = { scan_location: '/test/path', eol_true_count: 5 }; + + mod.track('test-event-1', () => initialProperties); + + const getUndefinedProperties = sinon.stub().callsFake((context) => { + assert.strictEqual(context.scan_location, '/test/path'); + assert.strictEqual(context.eol_true_count, 5); + return undefined; + }); + + mod.track('test-event-2', getUndefinedProperties); + + assert(getUndefinedProperties.calledOnce); + assert(mockAmplitude.track.calledTwice); + + // Second track call should have undefined properties + const secondTrackCall = mockAmplitude.track.getCall(1); + assert.strictEqual(secondTrackCall.args[1], undefined); + }); + + it('should pass correct device_id and session_id to amplitude track', async (t) => { + const mod = await setupModule(t); + mod.track('test-event'); + + assert(mockAmplitude.track.calledOnce); + const trackCall = mockAmplitude.track.getCall(0); + const eventOptions = trackCall.args[2]; + + assert.strictEqual(eventOptions.device_id, 'test-machine-id'); + assert(typeof eventOptions.session_id === 'number'); + assert(eventOptions.session_id > 0); + }); + }); + + describe('Module Initialization', () => { + it('should initialize device_id using NodeMachineId.machineIdSync', async (t) => { + await setupModule(t); + + assert(mockNodeMachineId.machineIdSync.calledOnce); + assert.strictEqual(mockNodeMachineId.machineIdSync.getCall(0).args[0], true); + }); + + it('should initialize started_at as a Date object', async (t) => { + const beforeImport = Date.now(); + const mod = await setupModule(t); + const afterImport = Date.now(); + + mod.initializeAnalytics(); + + const identifyCall = mockAmplitude.identify.getCall(0); + const sessionId = identifyCall.args[1].session_id; + + assert(sessionId >= beforeImport); + assert(sessionId <= afterImport); + }); + + it('should initialize session_id as timestamp from started_at', async (t) => { + const mod = await setupModule(t); + mod.initializeAnalytics(); + + const identifyCall = mockAmplitude.identify.getCall(0); + const sessionId = identifyCall.args[1].session_id; + + // Session ID should be a valid timestamp + assert(typeof sessionId === 'number'); + assert(sessionId > 0); + assert(sessionId <= Date.now()); + }); + + it('should initialize defaultAnalyticsContext with correct locale', async (t) => { + const mod = await setupModule(t); + + const getProperties = sinon.stub().callsFake((context) => { + assert(typeof context.locale === 'string'); + assert(context.locale.length > 0); + return {}; + }); + + mod.track('test-event', getProperties); + assert(getProperties.calledOnce); + }); + + it('should initialize defaultAnalyticsContext with correct OS platform', async (t) => { + const mod = await setupModule(t); + + const getProperties = sinon.stub().callsFake((context) => { + assert(typeof context.os_platform === 'string'); + assert( + ['darwin', 'linux', 'win32', 'freebsd', 'openbsd', 'android', 'aix', 'sunos'].includes(context.os_platform), + ); + return {}; + }); + + mod.track('test-event', getProperties); + assert(getProperties.calledOnce); + }); + + it('should initialize defaultAnalyticsContext with CLI version from npm_package_version or unknown', async (t) => { + const mod = await setupModule(t); + + const getProperties = sinon.stub().callsFake((context) => { + assert(typeof context.cli_version === 'string'); + assert(context.cli_version.length > 0); + return {}; + }); + + mod.track('test-event', getProperties); + assert(getProperties.calledOnce); + }); + + it('should initialize analyticsContext equal to defaultAnalyticsContext', async (t) => { + const mod = await setupModule(t); + + const getProperties = sinon.stub().callsFake((context) => { + assert(typeof context.locale === 'string'); + assert(typeof context.os_platform === 'string'); + assert(typeof context.os_release === 'string'); + assert(typeof context.cli_version === 'string'); + assert(context.started_at instanceof Date); + return {}; + }); + + mod.track('test-event', getProperties); + assert(getProperties.calledOnce); + }); + }); +}); diff --git a/test/service/cdx.svc.test.ts b/test/service/cdx.svc.test.ts index 29dd178..212b63f 100644 --- a/test/service/cdx.svc.test.ts +++ b/test/service/cdx.svc.test.ts @@ -1,33 +1,27 @@ import assert from 'node:assert'; -import { describe, it, mock } from 'node:test'; +import { type TestContext, describe, it, mock } from 'node:test'; + // Node <22 may not support mock.module; skip tests if unavailable const hasMockModule = typeof (mock as unknown as { module?: unknown }).module === 'function'; describe('cdx.svc createSbom', () => { - it('returns bomJson when cdxgen returns an object', { skip: !hasMockModule }, async () => { - const bomJson = { bomFormat: 'CycloneDX', specVersion: '1.6', components: [] }; - await mock.module('@cyclonedx/cdxgen', { - namedExports: { - // biome-ignore lint/suspicious/noExplicitAny: test-time ESM mock - createBom: async () => ({ bomJson }) as any, - }, + function setupModule({ createBom, t }: { createBom: () => Promise<{ bomJson: unknown } | null>; t: TestContext }) { + t.mock.module('@cyclonedx/cdxgen', { + namedExports: { createBom }, }); - const mod = await import('../../src/service/cdx.svc.ts'); + return import(import.meta.resolve(`../../src/service/cdx.svc.ts?${Math.random().toFixed(3)}`)); + } + + it('returns bomJson when cdxgen returns an object', { skip: !hasMockModule }, async (t) => { + const bomJson = { bomFormat: 'CycloneDX', specVersion: '1.6', components: [] }; + const mod = await setupModule({ createBom: async () => ({ bomJson }), t }); const res = await mod.createSbom('/tmp/project'); assert.deepStrictEqual(res, bomJson); - mock.restoreAll(); }); - it('throws when cdxgen returns a falsy value', { skip: !hasMockModule }, async () => { - await mock.module('@cyclonedx/cdxgen', { - namedExports: { - createBom: async () => null, - }, - }); - - const mod = await import('../../src/service/cdx.svc.ts'); + it('throws when cdxgen returns a falsy value', { skip: !hasMockModule }, async (t) => { + const mod = await setupModule({ createBom: async () => null, t }); await assert.rejects(() => mod.createSbom('/tmp/project'), /SBOM not generated/); - mock.restoreAll(); }); }); diff --git a/test/service/display.svc.test.ts b/test/service/display.svc.test.ts index 522f7d3..5179117 100644 --- a/test/service/display.svc.test.ts +++ b/test/service/display.svc.test.ts @@ -37,10 +37,28 @@ describe('display.svc', () => { purl: 'pkg:npm/test3@3.0.0', metadata: null, }, + { + purl: 'pkg:npm/%40scoped/package@1.0.0', + metadata: { + isEol: false, + eolAt: null, + eolReasons: [], + cve: [], + }, + }, + { + purl: 'pkg:maven/org.springframework/spring-core@5.3.21', + metadata: { + isEol: true, + eolAt: '2023-01-01T00:00:00.000Z', + eolReasons: ['End of life'], + cve: [], + }, + }, ], createdOn: new Date().toISOString(), metadata: { - totalComponentsCount: 3, + totalComponentsCount: 5, unknownComponentsCount: 1, }, }; @@ -49,13 +67,22 @@ describe('display.svc', () => { it('should count components by status correctly', () => { const counts = countComponentsByStatus(mockReport); - assert.strictEqual(counts.EOL, 1); - assert.strictEqual(counts.OK, 1); + assert.strictEqual(counts.EOL, 2); + assert.strictEqual(counts.OK, 2); assert.strictEqual(counts.UNKNOWN, 1); assert.strictEqual(counts.EOL_UPCOMING, 0); assert.strictEqual(counts.NES_AVAILABLE, 1); }); + it('should extract ecosystems correctly from various PURL formats', () => { + const counts = countComponentsByStatus(mockReport); + + // Should extract both npm and maven ecosystems + assert.ok(counts.ECOSYSTEMS.includes('npm')); + assert.ok(counts.ECOSYSTEMS.includes('maven')); + assert.strictEqual(counts.ECOSYSTEMS.length, 2); + }); + it('should handle empty report', () => { const emptyReport: EolReport = { id: 'empty', @@ -83,7 +110,7 @@ describe('display.svc', () => { assert.ok(lines.length > 0); assert.ok(lines.some((line) => line.includes('Scan results:'))); - assert.ok(lines.some((line) => line.includes('3 total packages scanned'))); + assert.ok(lines.some((line) => line.includes('5 total packages scanned'))); }); it('should handle empty scan results', () => {