diff --git a/package-lock.json b/package-lock.json index 2ac33b1c..7e37cadf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@opentelemetry/sdk-metrics": "^1.15.2", "@opentelemetry/semantic-conventions": "^1.15.2", "ajv": "^8.12.0", + "commander": "^11.0.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "object-scan": "^19.0.2", @@ -2139,7 +2140,6 @@ "version": "11.0.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", - "dev": true, "engines": { "node": ">=16" } @@ -6455,15 +6455,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/ts-json-schema-generator/node_modules/commander": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz", - "integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==", - "dev": true, - "engines": { - "node": ">=16" - } - }, "node_modules/ts-json-schema-generator/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", diff --git a/package.json b/package.json index 886445c8..c323ec0b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@opentelemetry/sdk-metrics": "^1.15.2", "@opentelemetry/semantic-conventions": "^1.15.2", "ajv": "^8.12.0", + "commander": "^11.0.0", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "object-scan": "^19.0.2", diff --git a/src/main/core/anonymize.ts b/src/main/core/anonymize.ts new file mode 100644 index 00000000..582b64f7 --- /dev/null +++ b/src/main/core/anonymize.ts @@ -0,0 +1,55 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { type Attributes } from '@opentelemetry/api' +import { createHash } from 'crypto' + +type RequireAtLeastOne = { + [K in keyof T]-?: Required> & Partial>> +}[keyof T] + +interface AnonymizeConfig { + hash?: Array + substitute?: Array +} + +/** + * Anonymizes incoming raw data. The keys to be anonymized are specified in the config object + * under either the `hash` key or the `substitute` key. + * + * @param raw - The attributes to anonymize. + * @param config - The keys to either hash or substitute. + * @returns The raw object will all specified keys replaced with anonymized versions of their + * values. + */ +export function anonymize( + raw: T, + config: RequireAtLeastOne> +) { + const anonymizedEntries = Object.entries(raw).map(([key, value]) => { + if (typeof value !== 'string') { + return { key, value } + } + + if (config.hash?.includes(key) ?? false) { + const hash = createHash('sha256') + hash.update(value) + return { key, value: hash.digest('hex') } + } + + if (config.substitute?.includes(key) ?? false) { + // TODO: implement this logic + return { key, value: 'substituted!' } + } + + return { key, value } + }) + + return anonymizedEntries.reduce((prev, cur) => { + prev[cur.key] = cur.value + return prev + }, {}) +} diff --git a/src/main/core/config/config-validator.ts b/src/main/core/config/config-validator.ts index c8aa4d94..bf17be97 100644 --- a/src/main/core/config/config-validator.ts +++ b/src/main/core/config/config-validator.ts @@ -9,6 +9,9 @@ import ajv, { type JSONSchemaType, type ValidateFunction } from 'ajv' // TODO: this should come from a separate published package import { type Schema as ConfigFileSchema } from '../../../schemas/Schema.js' import { ConfigValidationError } from '../../exceptions/config-validation-error.js' +import { Loggable } from '../log/loggable.js' +import { type Logger } from '../log/logger.js' +import { Trace } from '../log/trace.js' const Ajv = ajv.default @@ -16,16 +19,18 @@ const Ajv = ajv.default * Class that validates a telemetrics configuration file. Instances of this class should not be used * to analyze more than one config file. Instead, create new instances for separate validations. */ -export class ConfigValidator { - private readonly validate: ValidateFunction +export class ConfigValidator extends Loggable { + private readonly ajvValidate: ValidateFunction /** * Constructs a new config file validator based on the provided config file schema. * * @param schema - Config file schema object. + * @param logger - A logger instance. */ - public constructor(schema: JSONSchemaType) { - this.validate = new Ajv({ allErrors: true, verbose: true }).compile(schema) + public constructor(schema: JSONSchemaType, logger: Logger) { + super(logger) + this.ajvValidate = new Ajv({ allErrors: true, verbose: true }).compile(schema) } /** @@ -37,11 +42,12 @@ export class ConfigValidator { * @throws `ConfigValidationError` if the file did not pass schema validation. * @returns True if the config file passed validation; does not return otherwise. */ - public validateConfig(content: unknown): content is ConfigFileSchema { - if (!this.validate(content)) { + @Trace() + public validate(content: unknown): content is ConfigFileSchema { + if (!this.ajvValidate(content)) { throw new ConfigValidationError( // Construct an array of partial error objects to cut down on log/output noise - this.validate.errors?.map((err) => { + this.ajvValidate.errors?.map((err) => { const { instancePath, keyword, message, params } = err return { instancePath, diff --git a/src/main/core/directory-enumerator.ts b/src/main/core/directory-enumerator.ts new file mode 100644 index 00000000..459adbd2 --- /dev/null +++ b/src/main/core/directory-enumerator.ts @@ -0,0 +1,53 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path' + +import { InvalidRootPathError } from '../exceptions/invalid-root-path-error.js' +import { Loggable } from './log/loggable.js' +import { Trace } from './log/trace.js' + +/** + * Class capable of enumerating directories based on a leaf dir, root dir, and predicate function. + */ +export class DirectoryEnumerator extends Loggable { + /** + * Finds directories between leaf and root (inclusive) which satisfy the predicate. + * + * @param leaf - Leaf-most directory. This must be inside of the root directory. + * @param root - Root-most directory. + * @param predicate - Function to indicate whether or not each enumerated directory should be part + * of the result set. + * @returns A (possibly empty) array of directories. + */ + @Trace() + public async find( + leaf: string, + root: string, + predicate: (dir: string) => boolean | Promise + ): Promise { + // Ensure the format is normalized + leaf = path.resolve(leaf) + root = path.resolve(root) + + // (if leaf is not a subpath of root, throw an exception) + if (path.relative(root, leaf).startsWith('..')) { + throw new InvalidRootPathError(root, leaf) + } + + const dirs = [] + + for (let cur = leaf; cur !== root; cur = path.resolve(cur, '..')) { + dirs.push(cur) + } + dirs.push(root) + + const checks = await Promise.all(dirs.map(predicate)) + + return dirs.filter((_dir, index) => checks[index] === true) + } +} diff --git a/src/main/core/get-project-root.ts b/src/main/core/get-project-root.ts index 4731773c..374e8088 100644 --- a/src/main/core/get-project-root.ts +++ b/src/main/core/get-project-root.ts @@ -4,17 +4,19 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ +import { type Logger } from './log/logger.js' import { runCommand } from './run-command.js' /** * Finds and returns the root-most directory of the analyzed project's source tree. * * @param cwd - Current working directory to use as the basis for finding the root directory. + * @param logger - Logger instance. * @throws An exception if no usable root data was obtained. * @returns The path of the analyzed project's root directory or null. */ -export async function getProjectRoot(cwd: string): Promise { - const result = await runCommand('git rev-parse --show-toplevel', { cwd }) +export async function getProjectRoot(cwd: string, logger: Logger): Promise { + const result = await runCommand('git rev-parse --show-toplevel', logger, { cwd }) return result.stdout } diff --git a/src/main/core/initialize-open-telemetry.ts b/src/main/core/initialize-open-telemetry.ts index 2e66344e..da9a56f1 100644 --- a/src/main/core/initialize-open-telemetry.ts +++ b/src/main/core/initialize-open-telemetry.ts @@ -4,24 +4,12 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import opentelemetry from '@opentelemetry/api' +import opentelemetry, { type Attributes } from '@opentelemetry/api' import { Resource } from '@opentelemetry/resources' import { MeterProvider } from '@opentelemetry/sdk-metrics' import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' import { ManualMetricReader } from './manual-metric-reader.js' -import type * as ResourceAttributes from './resource-attributes.js' - -interface InitializeOpenTelemetryConfig { - [ResourceAttributes.EMITTER_NAME]: string - [ResourceAttributes.EMITTER_VERSION]: string - [ResourceAttributes.PROJECT_ID]: string - [ResourceAttributes.ANALYZED_RAW]: string - [ResourceAttributes.ANALYZED_HOST]: string | undefined - [ResourceAttributes.ANALYZED_OWNER]: string | undefined - [ResourceAttributes.ANALYZED_REPOSITORY]: string | undefined - [ResourceAttributes.DATE]: string -} /** * Initializes the OpenTelemetry tooling based on the provided config. @@ -29,7 +17,7 @@ interface InitializeOpenTelemetryConfig { * @param config - The configuration options needed to initialize OpenTelemetry. * @returns A metric reader that will contain all collected data. */ -function initializeOpenTelemetry(config: InitializeOpenTelemetryConfig) { +function initializeOpenTelemetry(config: Attributes) { const resource = Resource.default().merge( new Resource({ // By default, remove the service name attribute, since it is unused diff --git a/src/main/core/log/loggable.ts b/src/main/core/log/loggable.ts index 936daa25..82fe7fa0 100644 --- a/src/main/core/log/loggable.ts +++ b/src/main/core/log/loggable.ts @@ -9,6 +9,15 @@ import { type Logger } from './logger.js' /** * Abstract class which enforces that all child classes define a logger instance. */ -export abstract class Loggable { - protected abstract logger: Logger +export class Loggable { + protected logger: Logger + + /** + * Constructs a loggable class. + * + * @param logger - The logger to use during logging. + */ + public constructor(logger: Logger) { + this.logger = logger + } } diff --git a/src/main/core/log/logger.ts b/src/main/core/log/logger.ts index 479a5eb8..a5db5d45 100644 --- a/src/main/core/log/logger.ts +++ b/src/main/core/log/logger.ts @@ -24,15 +24,46 @@ export class Logger { } /** - * Logs a given message to the log file. + * Debug logs a given log message. * - * @param level - 'debug' or 'error'. - * @param msg - Message to log, can be a string or instance of Error class. + * @param msg - The message to log. */ - public async log(level: Level, msg: string | Error) { + public async debug(msg: string) { + await this.log('debug', msg) + } + + /** + * Error logs a given message/error. + * + * @param msg - The message/error to log. + */ + public async error(msg: string | Error) { if (msg instanceof Error) { msg = msg.stack ?? msg.name + ' ' + msg.message } - await appendFile(this.filePath, `${level} ${new Date().toISOString()} ${msg}\n`) + + await this.log('error', msg) + } + + /** + * Logs a given message to the log file. + * + * @param level - 'debug' or 'error'. + * @param msg - Message to log. + */ + private async log(level: Level, msg: string) { + const date = new Date().toISOString() + + if (process.env['NODE_ENV'] !== 'production') { + switch (level) { + case 'debug': + console.debug(level, date, msg) + break + case 'error': + console.error(level, date, msg) + } + } + + await appendFile(this.filePath, level + ' ' + date + ' ' + msg + '\n') } } diff --git a/src/main/core/log/trace.ts b/src/main/core/log/trace.ts index 81203262..d9f94462 100644 --- a/src/main/core/log/trace.ts +++ b/src/main/core/log/trace.ts @@ -28,7 +28,7 @@ const MAX_ARGS_STRING_LENGTH = 500 // characters * @returns A decorated method. */ function Trace(): MethodDecorator { - return function methodDecorator(_target, propertyKey, descriptor) { + return function methodDecorator(target, propertyKey, descriptor) { if ( descriptor.value === null || descriptor.value === undefined || @@ -37,6 +37,8 @@ function Trace(): MethodDecorator { return } + const targetName = target.constructor.name + // Adjust type to represent a descriptor that is guaranteed to have a value const descriptorWithValue = descriptor as typeof descriptor & { value: NonNullable<(typeof descriptor)['value']> @@ -53,7 +55,7 @@ function Trace(): MethodDecorator { } setImmediate(() => { - void traceEnter(logger, String(propertyKey), args) + void traceEnter(logger, targetName, String(propertyKey), args) }) let result: unknown @@ -61,14 +63,14 @@ function Trace(): MethodDecorator { result = original.apply(this, args) } catch (e) { setImmediate(() => { - void traceExit(logger, String(propertyKey), e) + void traceExit(logger, targetName, String(propertyKey), e) }) throw e } setImmediate(() => { - void traceExit(logger, String(propertyKey), result) + void traceExit(logger, targetName, String(propertyKey), result) }) return result @@ -84,29 +86,30 @@ function Trace(): MethodDecorator { } } -async function traceEnter(logger: Logger, methodName: string, args: unknown[]) { +async function traceEnter(logger: Logger, targetName: string, methodName: string, args: unknown[]) { const stringArgs = truncateString(String(args.map(safeStringify)), MAX_ARGS_STRING_LENGTH) - await logger.log('debug', `-> ${methodName}(${stringArgs})`) + await logger.debug(`-> ${targetName}::${methodName}(${stringArgs})`) } -async function traceExit(logger: Logger, methodName: string, result: unknown) { +async function traceExit(logger: Logger, targetName: string, methodName: string, result: unknown) { if (result instanceof Promise) { result.then( async (value: unknown) => { - await logger.log( - 'debug', - `<- ${methodName} <- ${truncateString(safeStringify(value), MAX_ARGS_STRING_LENGTH)}` + await logger.debug( + `<- ${targetName}::${methodName} <- ${truncateString( + safeStringify(value), + MAX_ARGS_STRING_LENGTH + )}` ) }, async (err: unknown) => { - await logger.log('debug', `-x- ${methodName} <- ${err?.toString() ?? ''}`) + await logger.debug(`-x- ${targetName}::${methodName} <- ${err?.toString() ?? ''}`) } ) } else { - await logger.log( - 'debug', - `${result instanceof Error ? '-x-' : '<-'} ${methodName} <- ${ + await logger.debug( + `${result instanceof Error ? '-x-' : '<-'} ${targetName}::${methodName} <- ${ result instanceof Error ? result.toString() : truncateString(safeStringify(result), MAX_ARGS_STRING_LENGTH) diff --git a/src/main/core/parse-yaml-file.ts b/src/main/core/parse-yaml-file.ts index 97f48d5e..bf49902d 100644 --- a/src/main/core/parse-yaml-file.ts +++ b/src/main/core/parse-yaml-file.ts @@ -14,10 +14,17 @@ import yaml from 'js-yaml' * * @param filePath - Path to the yaml file. * @returns Object containing parsed content. - * @throws A YAMLException exception if there is an error parsing the file, or an ENOENT Error if - * the config file could not be found. + * @throws A YAMLException exception if there is an error parsing the file, or an ENOENT wrapped in + * an Error if the config file could not be found. */ -export async function parseYamlFile(filePath: string) { - const contents = await readFile(filePath, 'utf8') - return yaml.load(contents) +export async function parseYamlFile(filePath: string): Promise> { + let contents + + try { + contents = await readFile(filePath, 'utf8') + } catch (e) { + throw new Error(String(e)) + } + + return yaml.load(contents) as Record } diff --git a/src/main/core/run-command.ts b/src/main/core/run-command.ts index 9b02d991..6a282bb4 100644 --- a/src/main/core/run-command.ts +++ b/src/main/core/run-command.ts @@ -8,8 +8,9 @@ import childProcess from 'node:child_process' import { RunCommandError } from '../exceptions/run-command-error.js' import { guardShell } from './guard-shell.js' +import { type Logger } from './log/logger.js' -interface Result { +export interface RunCommandResult { stdout: string stderr: string exitCode: number @@ -20,6 +21,7 @@ interface Result { * non-zero exit code. * * @param cmd - The command to invoke. + * @param logger - Instance to use for logging. * @param options - Options to include along with the command. * @param rejectOnError - Whether or not to reject the resulting promise when a non-zero exit code * is encountered. @@ -27,58 +29,75 @@ interface Result { */ export async function runCommand( cmd: string, + logger: Logger, options: childProcess.SpawnOptions = {}, rejectOnError: boolean = true ) { + await logger.debug('Running command: ' + cmd) + guardShell(cmd) - const execOptions = { + let resolveFn: (value: RunCommandResult) => void + let rejectFn: (reason: unknown) => void + let outData = '' + let errorData = '' + + const spawnOptions = { env: process.env, shell: true, ...options } + const promise = new Promise((resolve, reject) => { + resolveFn = resolve + rejectFn = reject + }) + const proc = childProcess.spawn(cmd, spawnOptions) - return await new Promise((resolve, reject) => { - let outData = '' - let errorData = '' - - const proc = childProcess.spawn(cmd, execOptions) - - proc.stdout?.on('data', (data) => { - outData += data.toString() - }) + proc.stdout?.on('data', (data) => { + outData += data.toString() + }) - proc.stderr?.on('data', (data) => { - errorData += data.toString() - }) + proc.stderr?.on('data', (data) => { + errorData += data.toString() + }) - proc.on('error', (err) => { - if (rejectOnError) { - reject(err) - } else { - resolve({ + proc.on('error', (err) => { + if (rejectOnError) { + rejectFn( + new RunCommandError({ exitCode: 'errno' in err && typeof err.errno === 'number' ? err.errno : -1, - stderr: errorData, - stdout: outData + stderr: errorData.trim(), + stdout: outData.trim(), + exception: err, + spawnOptions }) - } - }) - - proc.on('close', (exitCode) => { - if (exitCode !== 0 && rejectOnError) { - reject( - new RunCommandError({ - exitCode: exitCode ?? 999, - stderr: errorData.trim(), - stdout: outData.trim() - }) - ) - } - resolve({ - exitCode: exitCode ?? 999, - stderr: errorData.trim(), - stdout: outData.trim() + ) + } else { + resolveFn({ + exitCode: 'errno' in err && typeof err.errno === 'number' ? err.errno : -1, + stderr: errorData, + stdout: outData }) + } + }) + + proc.on('close', (exitCode) => { + if (exitCode !== 0 && rejectOnError) { + rejectFn( + new RunCommandError({ + exitCode: exitCode ?? 999, + stderr: errorData.trim(), + stdout: outData.trim(), + spawnOptions + }) + ) + } + resolveFn({ + exitCode: exitCode ?? 999, + stderr: errorData.trim(), + stdout: outData.trim() }) }) + + return await promise } diff --git a/src/main/core/scope-metric.ts b/src/main/core/scope-metric.ts index 50a1a4db..36c414a1 100644 --- a/src/main/core/scope-metric.ts +++ b/src/main/core/scope-metric.ts @@ -20,24 +20,4 @@ export abstract class ScopeMetric { * Get all OpenTelemetry Attributes for this metric data point. */ public abstract get attributes(): Attributes - - /** - * TODO. - * - * @param _val - The value to hash. - * @throws Error because not yet implemented. - */ - protected hash(_val: string): string { - throw new Error('Method not yet implemented.') - } - - /** - * TODO. - * - * @param _val - The value to hash. - * @throws Error because not yet implemented. - */ - protected substitute(_val: string): string { - throw new Error('Method not yet implemented') - } } diff --git a/src/main/core/scope.ts b/src/main/core/scope.ts index f89aba24..46923483 100644 --- a/src/main/core/scope.ts +++ b/src/main/core/scope.ts @@ -6,7 +6,10 @@ */ import opentelemetry, { type Counter, type Meter } from '@opentelemetry/api' +// TODO: this should come from a separate published package +import { type Schema as Config } from '../../schemas/Schema.js' import { Loggable } from './log/loggable.js' +import { type Logger } from './log/logger.js' import { type ScopeMetric } from './scope-metric.js' /** @@ -22,7 +25,7 @@ export abstract class Scope extends Loggable { /** * The OpenTelemetry-style name of this scope to be used in data transfer and storage. */ - public abstract readonly name: string + public abstract readonly name: keyof Config['collect'] /** * Entry point for the scope. All scopes run asynchronously. @@ -34,13 +37,26 @@ export abstract class Scope extends Loggable { */ public readonly metrics: Record - private scope?: Meter + protected readonly config: Config + protected readonly cwd: string + protected readonly root: string + + private scopeMeter?: Meter /** * Instantiates a new scope. + * + * @param cwd - Current working directory to use when running this scope. + * @param root - The root-most directory to consider when running this scope. + * @param config - An object representation of the config file. + * @param logger - Logger instance to use during logging. */ - protected constructor() { - super() + public constructor(cwd: string, root: string, config: Scope['config'], logger: Logger) { + super(logger) + + this.cwd = cwd + this.root = root + this.config = config this.metrics = {} } @@ -51,13 +67,13 @@ export abstract class Scope extends Loggable { */ public capture(dataPoint: ScopeMetric): void { // Ensure a scope exists - if (this.scope === undefined) { - this.scope = opentelemetry.metrics.getMeter(this.name) + if (this.scopeMeter === undefined) { + this.scopeMeter = opentelemetry.metrics.getMeter(this.name) } // Ensure a counter exists if (this.metrics[dataPoint.name] === undefined) { - this.metrics[dataPoint.name] = this.scope.createCounter(dataPoint.name) + this.metrics[dataPoint.name] = this.scopeMeter.createCounter(dataPoint.name) } // Log the metric diff --git a/src/main/exceptions/run-command-error.ts b/src/main/exceptions/run-command-error.ts index 649fd590..645b62c8 100644 --- a/src/main/exceptions/run-command-error.ts +++ b/src/main/exceptions/run-command-error.ts @@ -5,10 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -interface RunCommandErrorReason { - exitCode: number - stderr: string - stdout: string +import { type SpawnOptions } from 'child_process' + +import { type RunCommandResult } from '../core/run-command.js' + +export interface RunCommandErrorReason extends RunCommandResult { + exception?: unknown + spawnOptions: SpawnOptions } /** @@ -16,6 +19,6 @@ interface RunCommandErrorReason { */ export class RunCommandError extends Error { constructor(reason: RunCommandErrorReason) { - super(JSON.stringify(reason)) + super(JSON.stringify(reason, undefined, 2)) } } diff --git a/src/main/exceptions/unknown-scope-error.ts b/src/main/exceptions/unknown-scope-error.ts new file mode 100644 index 00000000..1fe1b8ad --- /dev/null +++ b/src/main/exceptions/unknown-scope-error.ts @@ -0,0 +1,15 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Error indicating that a scope name was unable to be found in the scope registry. + */ +export class UnknownScopeError extends Error { + constructor(scopeName: string) { + super('Unknown scope: ' + scopeName) + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 51c6dc29..4c682cf4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,71 +1,66 @@ -#!/usr/bin/env node /* * Copyright IBM Corp. 2023, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ +import path from 'node:path' + +import { Command } from 'commander' -import { initializeOpenTelemetry } from './core/initialize-open-telemetry.js' import { createLogFilePath } from './core/log/create-log-file-path.js' import { Logger } from './core/log/logger.js' -import * as ResourceAttributes from './core/resource-attributes.js' -import { runCommand } from './core/run-command.js' -import { tokenizeRepository } from './core/tokenize-repository.js' -import { getTelemetryPackageData } from './scopes/npm/get-telemetry-package-data.js' +import { TelemetryCollector } from './telemetry-collector.js' -/* +interface CommandLineOptions { + config: string +} -1. read command line arguments -2. do config file stuff -3. do a bunch of open telemetry setup stuff -4. create a bunch of scopes -5. run the scopes -6. gather data -7. send data +/** + * Sets up Commander, registers the command action, and invokes the action. + */ +async function run() { + const program = new Command() + .description('Collect telemetry data for a package.') + .requiredOption('--config ', 'Path to a telemetrics configuration file') + .action(collect) -*/ + try { + await program.parseAsync() + } catch (err) { + // As a failsafe, this catches any uncaught exception, prints it to stderr, and silently exits + console.error(err) + } +} /** - * Runs data collection. + * This is the main entrypoint for telemetry collection. + * + * @param opts - The command line options provided when the program was executed. */ -async function run() { +async function collect(opts: CommandLineOptions) { const date = new Date().toISOString() const logFilePath = await createLogFilePath(date) - const logger = new Logger(logFilePath) - // TODO: remove this test code - await logger.log('debug', 'hello world') - await logger.log('error', new Error('wow cool')) - - // parseConfigFile() - const config = { - projectId: 'abecafa7681dfd65cc' - } - - const { name: telemetryName, version: telemetryVersion } = await getTelemetryPackageData() - - // TODO: handle non-existant remote - // TODO: move this logic elsewhere - const gitOrigin = await runCommand('git remote get-url origin') - const repository = tokenizeRepository(gitOrigin.stdout) + // TODO: this should come from an external package or be bundled + const configSchemaPath = path.join( + path.dirname(import.meta.url.substring(7)), + '../../src/schemas/telemetrics-config.schema.json' + ) - const metricReader = initializeOpenTelemetry({ - [ResourceAttributes.EMITTER_NAME]: telemetryName, - [ResourceAttributes.EMITTER_VERSION]: telemetryVersion, - [ResourceAttributes.PROJECT_ID]: config.projectId, - [ResourceAttributes.ANALYZED_RAW]: gitOrigin.stdout, - [ResourceAttributes.ANALYZED_HOST]: repository.host, - [ResourceAttributes.ANALYZED_OWNER]: repository.owner, - [ResourceAttributes.ANALYZED_REPOSITORY]: repository.repository, - [ResourceAttributes.DATE]: date - }) + const telemetryCollector = new TelemetryCollector(opts.config, configSchemaPath, logger) - const results = await metricReader.collect() - - // TODO: remove this test line - console.log(JSON.stringify(results, null, 2)) + try { + await telemetryCollector.run() + } catch (err) { + // Catch any exception thrown, log it, and quietly exit + if (err instanceof Error) { + await logger.error(err) + } else { + await logger.error(String(err)) + } + } } await run() diff --git a/src/main/scopes/npm/find-installing-packages.ts b/src/main/scopes/npm/find-installing-packages.ts deleted file mode 100644 index 6013f2ab..00000000 --- a/src/main/scopes/npm/find-installing-packages.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright IBM Corp. 2023, 2023 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import { runCommand } from '../../core/run-command.js' -import { findInstallersFromTree } from './find-installers-from-tree.js' -import { findScannableDirectories } from './find-scannable-directories.js' -import { type InstallingPackage } from './interfaces.js' - -/** - * Finds all packages within the project that installed the specified package at the specified - * version. This is done by starting at the current directory and traversing up the directory - * structure until an `npm ls` command on one of those directories returns a non-empty list of - * installers. - * - * If no installers were found after the root-post project directory was searched, an empty array is - * returned. - * - * @param cwd - Current working directory to use when finding installing packages. - * @param root - The root-most directory to consider when searching for installers. - * @param packageName - The name of the package to search for. - * @param packageVersion - The exact version of the package to search for. - * @returns A possibly empty array of installing packages. - */ -export async function findInstallingPackages( - cwd: string, - root: string, - packageName: string, - packageVersion: string -): Promise { - const dirs = await findScannableDirectories(cwd, root) - - let installers: InstallingPackage[] = [] - - for (const d of dirs) { - // Allow this command to try and obtain results even if it exited with a total or partial error - const result = await runCommand('npm ls --all --json', { cwd: d }, false) - - const dependencyTree = JSON.parse(result.stdout) - - installers = findInstallersFromTree(dependencyTree, packageName, packageVersion) - - if (installers.length > 0) { - break - } - } - - return installers -} diff --git a/src/main/scopes/npm/find-scannable-directories.ts b/src/main/scopes/npm/find-scannable-directories.ts deleted file mode 100644 index 416a8b09..00000000 --- a/src/main/scopes/npm/find-scannable-directories.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright IBM Corp. 2023, 2023 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import path from 'node:path' - -import { InvalidRootPathError } from '../../exceptions/invalid-root-path-error.js' -import { hasNodeModulesFolder } from './has-node-modules-folder.js' - -/** - * Given a root directory and a current directory, finds all directories between the root and - * current which contain a node_modules folder. This indicates that they can be scanned for - * dependency information via an `npm ls` command. - * - * @param cwd - The current/starting directory. - * @param root - The root-most directory to consider. - * @throws If the cwd is not a sub-path of the root. - * @returns A (possibly empty) array of directory path strings. - */ -export async function findScannableDirectories(cwd: string, root: string) { - const dirs = [] - // Ensure the format is normalized - root = path.resolve(root) - cwd = path.resolve(cwd) - - // (if cwd is not a subpath of root, throw an exception) - if (path.relative(root, cwd).startsWith('..')) { - throw new InvalidRootPathError(root, cwd) - } - - do { - if (await hasNodeModulesFolder(cwd)) { - dirs.push(cwd) - } - - cwd = path.resolve(cwd, '..') - } while (cwd !== root) - - if (await hasNodeModulesFolder(root)) { - dirs.push(root) - } - - return dirs -} diff --git a/src/main/scopes/npm/get-package-data.ts b/src/main/scopes/npm/get-package-data.ts index f2da9401..7e963548 100644 --- a/src/main/scopes/npm/get-package-data.ts +++ b/src/main/scopes/npm/get-package-data.ts @@ -4,6 +4,7 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ +import { type Logger } from '../../core/log/logger.js' import { runCommand } from '../../core/run-command.js' import { type PackageData } from './interfaces.js' @@ -12,10 +13,11 @@ import { type PackageData } from './interfaces.js' * * @param packagePath - A directory to be treated as a package. It may or may not include a * package.json file directly in it. + * @param logger - Logger instance. * @throws If no package details could be obtained or the directory didn't point to a valid package. * @returns An object containing details about the package. */ -export async function getPackageData(packagePath: string): Promise { - const result = await runCommand('npm pkg get name version', { cwd: packagePath }) +export async function getPackageData(packagePath: string, logger: Logger): Promise { + const result = await runCommand('npm pkg get name version', logger, { cwd: packagePath }) return JSON.parse(result.stdout) } diff --git a/src/main/scopes/npm/get-telemetry-package-data.ts b/src/main/scopes/npm/get-telemetry-package-data.ts index d9a94e35..7ec2b6d7 100644 --- a/src/main/scopes/npm/get-telemetry-package-data.ts +++ b/src/main/scopes/npm/get-telemetry-package-data.ts @@ -6,17 +6,23 @@ */ import path from 'node:path' +import { type Logger } from '../../core/log/logger.js' import { getPackageData } from './get-package-data.js' /** * Get details about the currently running telemetry package by looking at the package.json file * closest to this file. * + * @param logger - Logger instance. * @returns An object with details about the currently running telemetry package. */ -export async function getTelemetryPackageData() { +export async function getTelemetryPackageData(logger: Logger) { // Remove leading file:// - const cwd = path.dirname(import.meta.url.substring(7)) + const currentFileDir = path.dirname(import.meta.url.substring(7)) - return await getPackageData(cwd) + await logger.debug( + 'getTelemetryPackageData: Current file directory discovered as: ' + currentFileDir + ) + + return await getPackageData(currentFileDir, logger) } diff --git a/src/main/scopes/npm/metrics/dependency-metric.ts b/src/main/scopes/npm/metrics/dependency-metric.ts index f4c50d39..b25b0eac 100644 --- a/src/main/scopes/npm/metrics/dependency-metric.ts +++ b/src/main/scopes/npm/metrics/dependency-metric.ts @@ -8,6 +8,7 @@ import { type Attributes } from '@opentelemetry/api' import { SemVer } from 'semver' +import { anonymize } from '../../../core/anonymize.js' import { ScopeMetric } from '../../../core/scope-metric.js' export interface DependencyData { @@ -37,18 +38,31 @@ export class DependencyMetric extends ScopeMetric { public override get attributes(): Attributes { const { owner, name, major, minor, patch, preRelease } = this.getPackageDetails() - return { - raw: this.data.name, - owner, - name, - 'installer.name': this.data.installerName, - 'installer.version': this.data.installerVersion, - 'version.raw': this.data.version, - 'version.major': major.toString(), - 'version.minor': minor.toString(), - 'version.patch': patch.toString(), - 'version.preRelease': preRelease.join('.') - } + return anonymize( + { + raw: this.data.name, + owner, + name, + 'installer.name': this.data.installerName, + 'installer.version': this.data.installerVersion, + 'version.raw': this.data.version, + 'version.major': major.toString(), + 'version.minor': minor.toString(), + 'version.patch': patch.toString(), + 'version.preRelease': preRelease.length > 0 ? preRelease.join('.') : undefined + }, + { + hash: [ + 'raw', + 'owner', + 'name', + 'installer.name', + 'installer.version', + 'version.raw', + 'version.preRelease' + ] + } + ) } /** diff --git a/src/main/scopes/npm/npm-scope.ts b/src/main/scopes/npm/npm-scope.ts index 3fea30c5..446adb55 100644 --- a/src/main/scopes/npm/npm-scope.ts +++ b/src/main/scopes/npm/npm-scope.ts @@ -4,45 +4,29 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import { type Logger } from '../../core/log/logger.js' +import { DirectoryEnumerator } from '../../core/directory-enumerator.js' import { Trace } from '../../core/log/trace.js' +import { runCommand } from '../../core/run-command.js' import { Scope } from '../../core/scope.js' -import { findInstallingPackages } from './find-installing-packages.js' +import { findInstallersFromTree } from './find-installers-from-tree.js' import { getPackageData } from './get-package-data.js' +import { hasNodeModulesFolder } from './has-node-modules-folder.js' +import { type InstallingPackage } from './interfaces.js' import { DependencyMetric } from './metrics/dependency-metric.js' /** * Scope class dedicated to data collection from an npm environment. */ export class NpmScope extends Scope { - private readonly cwd: string - private readonly root: string - protected override logger: Logger - public name = 'npm' - - /** - * Constructs an NpmScope. - * - * @param cwd - The directory representing the instrumented package. - * @param root - The root-most directory to consider for dependency information. - * @param logger - Injected logger dependency. - */ - public constructor(cwd: string, root: string, logger: Logger) { - super() - this.cwd = cwd - this.root = root - this.logger = logger - } + public override name = 'npm' as const @Trace() public override async run(): Promise { const { name: instrumentedPkgName, version: instrumentedPkgVersion } = await getPackageData( - this.cwd - ) - - const installingPackages = await findInstallingPackages( this.cwd, - this.root, + this.logger + ) + const installingPackages = await this.findInstallingPackages( instrumentedPkgName, instrumentedPkgVersion ) @@ -60,4 +44,47 @@ export class NpmScope extends Scope { }) }) } + + /** + * Finds all packages within the project that installed the specified package at the specified + * version. This is done by starting at the current directory and traversing up the directory + * structure until an `npm ls` command on one of those directories returns a non-empty list of + * installers. + * + * If no installers were found after the root-most project directory was searched, an empty array + * is returned. + * + * @param packageName - The name of the package to search for. + * @param packageVersion - The exact version of the package to search for. + * @returns A possibly empty array of installing packages. + */ + @Trace() + public async findInstallingPackages( + packageName: string, + packageVersion: string + ): Promise { + const dirs = await new DirectoryEnumerator(this.logger).find( + this.cwd, + this.root, + hasNodeModulesFolder + ) + + let installers: InstallingPackage[] = [] + + for (const d of dirs) { + // Allow this command to try and obtain results even if it exited with a total or partial + // error + const result = await runCommand('npm ls --all --json', this.logger, { cwd: d }, false) + + const dependencyTree = JSON.parse(result.stdout) + + installers = findInstallersFromTree(dependencyTree, packageName, packageVersion) + + if (installers.length > 0) { + break + } + } + + return installers + } } diff --git a/src/main/scopes/scope-registry.ts b/src/main/scopes/scope-registry.ts new file mode 100644 index 00000000..6da23ee7 --- /dev/null +++ b/src/main/scopes/scope-registry.ts @@ -0,0 +1,15 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { type Schema as Config } from '../../schemas/Schema.js' +import { type Scope } from '../core/scope.js' +import { NpmScope } from './npm/npm-scope.js' + +export const scopeRegistry: Record = { + jsx: undefined, + npm: NpmScope +} diff --git a/src/main/telemetry-collector.ts b/src/main/telemetry-collector.ts new file mode 100644 index 00000000..61663686 --- /dev/null +++ b/src/main/telemetry-collector.ts @@ -0,0 +1,162 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { readFile } from 'node:fs/promises' + +import { type Schema as Config } from '../schemas/Schema.js' +import { anonymize } from './core/anonymize.js' +import { ConfigValidator } from './core/config/config-validator.js' +import { getProjectRoot } from './core/get-project-root.js' +import { initializeOpenTelemetry } from './core/initialize-open-telemetry.js' +import { type Logger } from './core/log/logger.js' +import { Trace } from './core/log/trace.js' +import { parseYamlFile } from './core/parse-yaml-file.js' +import * as ResourceAttributes from './core/resource-attributes.js' +import { runCommand } from './core/run-command.js' +import { type Scope } from './core/scope.js' +import { tokenizeRepository } from './core/tokenize-repository.js' +import { UnknownScopeError } from './exceptions/unknown-scope-error.js' +import { getTelemetryPackageData } from './scopes/npm/get-telemetry-package-data.js' +import { scopeRegistry } from './scopes/scope-registry.js' + +/** + * Instantiable class capable of collecting project-wide JS-based telemetry data. + */ +export class TelemetryCollector { + private readonly configPath: string + private readonly configSchemaPath: string + private readonly logger: Logger + + /** + * Constructs a new telemetry collector. + * + * @param configPath - Path to a config file. + * @param configSchemaPath - Path to a schema against which to validate the config file. + * @param logger - A logger instance. + */ + public constructor(configPath: string, configSchemaPath: string, logger: Logger) { + this.configPath = configPath + this.configSchemaPath = configSchemaPath + this.logger = logger + } + + /** + * Runs telemetry data collection. + */ + @Trace() + public async run() { + const date = new Date().toISOString() + await this.logger.debug('Date: ' + date) + + const schemaFileContents = (await readFile(this.configSchemaPath)).toString() + await this.logger.debug('Schema: ' + schemaFileContents) + const configValidator = new ConfigValidator(JSON.parse(schemaFileContents), this.logger) + + const config = await parseYamlFile(this.configPath) + await this.logger.debug('Config: ' + JSON.stringify(config, undefined, 2)) + + const cwd = process.cwd() + await this.logger.debug('cwd: ' + cwd) + + const projectRoot = await getProjectRoot(cwd, this.logger) + await this.logger.debug('projectRoot: ' + projectRoot) + + if (!configValidator.validate(config)) { + // This will never be hit, but it allows code after this block to see the configData as + // being of type "Schema" + return + } + + // TODO: move this logic elsewhere + // TODO: handle non-existant remote + const gitOrigin = await runCommand('git remote get-url origin', this.logger) + const repository = tokenizeRepository(gitOrigin.stdout) + const emitterInfo = await getTelemetryPackageData(this.logger) + + const metricReader = initializeOpenTelemetry( + anonymize( + { + [ResourceAttributes.EMITTER_NAME]: emitterInfo.name, + [ResourceAttributes.EMITTER_VERSION]: emitterInfo.version, + [ResourceAttributes.PROJECT_ID]: config.projectId, + [ResourceAttributes.ANALYZED_RAW]: gitOrigin.stdout, + [ResourceAttributes.ANALYZED_HOST]: repository.host, + [ResourceAttributes.ANALYZED_OWNER]: repository.owner, + [ResourceAttributes.ANALYZED_REPOSITORY]: repository.repository, + [ResourceAttributes.DATE]: date + }, + { + hash: [ + ResourceAttributes.ANALYZED_RAW, + ResourceAttributes.ANALYZED_HOST, + ResourceAttributes.ANALYZED_OWNER, + ResourceAttributes.ANALYZED_REPOSITORY + ] + } + ) + ) + + const promises = this.runScopes(cwd, projectRoot, config) + + await Promise.allSettled(promises) + + const results = await metricReader.collect() + + /* + - instantiate an exporter + - transmit the data to the remote server + */ + + // TODO: remove this test line + console.log(JSON.stringify(results, undefined, 2)) + } + + /** + * Run all scopes defined in the provided config file against the provided cwd and root + * directories. + * + * @param cwd - The current working directory of telemetry data collection. + * @param root - The root directory of the project being analyzed. + * @param config - The provided config. + * @throws An error if an unknown scope is encountered in the config object. + * @returns A set of promises. One per executing scope. + */ + @Trace() + public runScopes(cwd: string, root: string, config: Config) { + const promises = [] + + for (const scopeName of Object.keys(config.collect) as Array) { + const ScopeClass = scopeRegistry[scopeName] + + if (ScopeClass === undefined) { + throw new UnknownScopeError(scopeName) + } + + const scopeInstance: Scope = Reflect.construct(ScopeClass, [cwd, root, config, this.logger]) + + // Catch here so that all scopes get a chance to run + promises.push( + scopeInstance + .run() + .then(async () => { + await this.logger.debug('Scope succeeded: ' + scopeName) + }) + .catch(async (reason) => { + await this.logger.error('Scope failed: ' + scopeName) + + if (reason instanceof Error) { + await this.logger.error(reason) + } else { + await this.logger.error(String(reason)) + } + }) + ) + } + + return promises + } +} diff --git a/src/test/core/anonymize.test.ts b/src/test/core/anonymize.test.ts new file mode 100644 index 00000000..1d063a9b --- /dev/null +++ b/src/test/core/anonymize.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { anonymize } from '../../main/core/anonymize.js' + +describe('anonymize', () => { + it('retains data when no keys are provided', () => { + const result = anonymize({ noTouch: 'keep me!' }, { hash: [] }) + + expect(result).toMatchObject({ noTouch: 'keep me!' }) + }) + + it('hashes a value when its key is provided', () => { + const result = anonymize({ hashMe: 'i better be hashed!' }, { hash: ['hashMe'] }) + + expect(result).toMatchObject({ + // This is the sha256 hash of "i better be hashed!" + hashMe: 'bdbf37e7035d0472a6f5d6fb94cae59a3244111a9afe29d4e77538257c7551a8' + }) + }) + + it('substitutes a value when its key is provided', () => { + const result = anonymize({ subMe: 'i better be subbed!' }, { substitute: ['subMe'] }) + + expect(result).toMatchObject({ + subMe: 'substituted!' + }) + }) + + it('tolerates an empty object', () => { + expect(() => anonymize({}, { hash: [] })).not.toThrow() + }) + + it('does not modify a number value', () => { + const result = anonymize({ dollars: 999 }, { hash: ['dollars'] }) + + expect(result).toMatchObject({ dollars: 999 }) + }) +}) diff --git a/src/test/core/config/config-validator.test.ts b/src/test/core/config/config-validator.test.ts index 56b3ab99..5e1c75d0 100644 --- a/src/test/core/config/config-validator.test.ts +++ b/src/test/core/config/config-validator.test.ts @@ -9,21 +9,25 @@ import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' import { ConfigValidator } from '../../../main/core/config/config-validator.js' +import { createLogFilePath } from '../../../main/core/log/create-log-file-path.js' +import { Logger } from '../../../main/core/log/logger.js' import { ConfigValidationError } from '../../../main/exceptions/config-validation-error.js' import { Fixture } from '../../__utils/fixture.js' +const logger = new Logger(await createLogFilePath(new Date().toISOString())) + // TODO: get this from the external package const schemaFile = 'src/schemas/telemetrics-config.schema.json' const schemaFileContents = (await readFile(schemaFile)).toString() -const validator = new ConfigValidator(JSON.parse(schemaFileContents)) +const validator = new ConfigValidator(JSON.parse(schemaFileContents), logger) -describe('configValidator', () => { +describe('class: ConfigValidator', () => { it('returns for a valid configuration', async () => { const fixture = new Fixture('config-files/valid/all-keys.yml') const config = await fixture.parse() expect(() => { - validator.validateConfig(config) + validator.validate(config) }).not.toThrow() }) @@ -32,7 +36,7 @@ describe('configValidator', () => { const config = await fixture.parse() expect(() => { - validator.validateConfig(config) + validator.validate(config) }).not.toThrow() }) @@ -42,7 +46,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -67,7 +71,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -92,7 +96,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -117,7 +121,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -142,7 +146,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -167,7 +171,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -192,7 +196,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -217,7 +221,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } @@ -242,7 +246,7 @@ describe('configValidator', () => { let err try { - validator.validateConfig(config) + validator.validate(config) } catch (e) { err = e } diff --git a/src/test/core/directory-enumerator.test.ts b/src/test/core/directory-enumerator.test.ts new file mode 100644 index 00000000..5bf92087 --- /dev/null +++ b/src/test/core/directory-enumerator.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { DirectoryEnumerator } from '../../main/core/directory-enumerator.js' +import { createLogFilePath } from '../../main/core/log/create-log-file-path.js' +import { Logger } from '../../main/core/log/logger.js' +import { InvalidRootPathError } from '../../main/exceptions/invalid-root-path-error.js' +import { hasNodeModulesFolder } from '../../main/scopes/npm/has-node-modules-folder.js' +import { Fixture } from '../__utils/fixture.js' + +const logger = new Logger(await createLogFilePath(new Date().toISOString())) + +describe('directoryEnumerator', () => { + it('correctly retrieves list of directories', async () => { + const rootFixture = new Fixture('projects/nested-node-modules-folders') + const fixture = new Fixture('projects/nested-node-modules-folders/node_modules/nested-dep') + const enumerator = new DirectoryEnumerator(logger) + + await expect( + enumerator.find(fixture.path, rootFixture.path, hasNodeModulesFolder) + ).resolves.toHaveLength(2) + }) + + it('returns empty if no valid dirs', async () => { + const rootFixture = new Fixture('') + const fixture = new Fixture('projects/no-node-modules-folders') + const enumerator = new DirectoryEnumerator(logger) + + await expect( + enumerator.find(fixture.path, rootFixture.path, hasNodeModulesFolder) + ).resolves.toHaveLength(0) + }) + + it('throws error when cwd is not contained in the root', async () => { + const rootFixture = new Fixture('a/b/c') + const fixture = new Fixture('d/e/f') + const enumerator = new DirectoryEnumerator(logger) + + await expect( + enumerator.find(fixture.path, rootFixture.path, hasNodeModulesFolder) + ).rejects.toThrow(InvalidRootPathError) + }) +}) diff --git a/src/test/core/get-project-root.test.ts b/src/test/core/get-project-root.test.ts index f3e28eeb..9de531ab 100644 --- a/src/test/core/get-project-root.test.ts +++ b/src/test/core/get-project-root.test.ts @@ -8,15 +8,22 @@ import path from 'path' import { describe, expect, it } from 'vitest' import { getProjectRoot } from '../../main/core/get-project-root.js' +import { createLogFilePath } from '../../main/core/log/create-log-file-path.js' +import { Logger } from '../../main/core/log/logger.js' +import { RunCommandError } from '../../main/exceptions/run-command-error.js' import { Fixture } from '../__utils/fixture.js' +const logger = new Logger(await createLogFilePath(new Date().toISOString())) + describe('getProjectRoot', () => { it('correctly gets project root', async () => { const fixture = new Fixture('projects/basic-project/node_modules') - await expect(getProjectRoot(path.resolve(fixture.path))).resolves.toMatch(/.*\/telemetrics-js/) + await expect(getProjectRoot(path.resolve(fixture.path), logger)).resolves.toMatch( + /.*\/telemetrics-js/ + ) }) it('throws error if no root exists', async () => { - await expect(getProjectRoot('does-not-exist')).rejects.toThrow('ENOENT') + await expect(getProjectRoot('does-not-exist', logger)).rejects.toThrow(RunCommandError) }) }) diff --git a/src/test/core/logger.test.ts b/src/test/core/logger.test.ts index 8f0d8599..08c7559b 100644 --- a/src/test/core/logger.test.ts +++ b/src/test/core/logger.test.ts @@ -19,7 +19,7 @@ describe('logger', () => { await expect(access(logFilePath)).rejects.toThrow('ENOENT') - await logger.log('debug', 'test log') + await logger.debug('test log') await expect(access(logFilePath)).resolves.toBeUndefined() @@ -40,14 +40,14 @@ describe('logger', () => { const errorLog = new Error('the error message') - await logger.log('debug', errorLog) + await logger.error(errorLog) await expect(access(logFilePath)).resolves.toBeUndefined() const content = await readFile(logFilePath, 'utf8') expect(content.length).toBeGreaterThan(0) - expect(content.startsWith('debug')).toBeTruthy() + expect(content.startsWith('error')).toBeTruthy() await unlink(logFilePath) }) diff --git a/src/test/core/scope.test.ts b/src/test/core/scope.test.ts index 774caa1f..6aa03a5a 100644 --- a/src/test/core/scope.test.ts +++ b/src/test/core/scope.test.ts @@ -17,15 +17,13 @@ describe('scope', () => { const logger = new Logger(await createLogFilePath(new Date().toISOString())) const myScope = new (class MyScope extends Scope { - public override name: string = 'my-scope' - protected override logger: Logger + public override name = 'npm' as const /** * Default constructor. */ public constructor() { - super() - this.logger = logger + super('', '', { collect: {}, projectId: '1234', version: 1 }, logger) } public override async run(): Promise { diff --git a/src/test/scopes/npm/__snapshots__/find-installing-packages.test.ts.snap b/src/test/scopes/npm/__snapshots__/find-installing-packages.test.ts.snap deleted file mode 100644 index 035096a3..00000000 --- a/src/test/scopes/npm/__snapshots__/find-installing-packages.test.ts.snap +++ /dev/null @@ -1,24 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`findInstallingPackages > correctly finds installing package data 1`] = ` -[ - { - "dependencies": [ - { - "name": "foo", - "version": "1.0.0", - }, - { - "name": "instrumented", - "version": "0.1.0", - }, - ], - "name": "basic-project", - "version": "1.0.0", - }, -] -`; - -exports[`findInstallingPackages > finds no results for an known package at an unknown version 1`] = `[]`; - -exports[`findInstallingPackages > finds no results for an unknown package 1`] = `[]`; diff --git a/src/test/scopes/npm/__snapshots__/npm-scope.e2e.test.ts.snap b/src/test/scopes/npm/__snapshots__/npm-scope.e2e.test.ts.snap index c0266cdf..01c2bc7f 100644 --- a/src/test/scopes/npm/__snapshots__/npm-scope.e2e.test.ts.snap +++ b/src/test/scopes/npm/__snapshots__/npm-scope.e2e.test.ts.snap @@ -1,6 +1,29 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`npmScope > correctly captures dependency data 1`] = ` +exports[`class: NpmScope > findInstallingPackages > correctly finds installing package data 1`] = ` +[ + { + "dependencies": [ + { + "name": "foo", + "version": "1.0.0", + }, + { + "name": "instrumented", + "version": "0.1.0", + }, + ], + "name": "basic-project", + "version": "1.0.0", + }, +] +`; + +exports[`class: NpmScope > findInstallingPackages > finds no results for an known package at an unknown version 1`] = `[]`; + +exports[`class: NpmScope > findInstallingPackages > finds no results for an unknown package 1`] = `[]`; + +exports[`class: NpmScope > run > correctly captures dependency data 1`] = ` { "errors": [], "resourceMetrics": { @@ -45,16 +68,16 @@ exports[`npmScope > correctly captures dependency data 1`] = ` "dataPoints": [ { "attributes": { - "installer.name": "basic-project", - "installer.version": "1.0.0", - "name": "foo", + "installer.name": "e497ed4e62a3b47d9cd8aaebefb6c0837b94fb8d710461ba203aa33ccf64cc4d", + "installer.version": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + "name": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", "owner": undefined, - "raw": "foo", + "raw": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", "version.major": "1", "version.minor": "0", "version.patch": "0", - "version.preRelease": "", - "version.raw": "1.0.0", + "version.preRelease": undefined, + "version.raw": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", }, "endTime": [ 0, @@ -68,16 +91,16 @@ exports[`npmScope > correctly captures dependency data 1`] = ` }, { "attributes": { - "installer.name": "basic-project", - "installer.version": "1.0.0", - "name": "instrumented", + "installer.name": "e497ed4e62a3b47d9cd8aaebefb6c0837b94fb8d710461ba203aa33ccf64cc4d", + "installer.version": "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305", + "name": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", "owner": undefined, - "raw": "instrumented", + "raw": "ec46e364d52dab2e207d33617267a84df5838926793c1b0a2974899fe28229f1", "version.major": "0", "version.minor": "1", "version.patch": "0", - "version.preRelease": "", - "version.raw": "0.1.0", + "version.preRelease": undefined, + "version.raw": "6ad9613a455798d6d92e5f5f390ab4baa70596bc869ed6b17f5cdd2b28635f06", }, "endTime": [ 0, diff --git a/src/test/scopes/npm/find-installing-packages.test.ts b/src/test/scopes/npm/find-installing-packages.test.ts deleted file mode 100644 index 1fe0c37e..00000000 --- a/src/test/scopes/npm/find-installing-packages.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright IBM Corp. 2023, 2023 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ -/* - * Copyright IBM Corp. 2023, 2023 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import path from 'path' -import { describe, expect, it } from 'vitest' - -import { findInstallingPackages } from '../../../main/scopes/npm/find-installing-packages.js' -import { Fixture } from '../../__utils/fixture.js' - -describe('findInstallingPackages', () => { - it('correctly finds installing package data', async () => { - const fixture = new Fixture('projects/basic-project/node_modules/instrumented') - const pkgs = await findInstallingPackages( - fixture.path, - path.join(fixture.path, '..', '..'), - 'instrumented', - '0.1.0' - ) - - expect(pkgs).toMatchSnapshot() - }) - - it('finds no results for an unknown package', async () => { - const fixture = new Fixture('projects/basic-project/node_modules/instrumented') - const pkgs = await findInstallingPackages( - fixture.path, - path.join(fixture.path, '..', '..'), - 'not-here', - '0.1.0' - ) - - expect(pkgs).toMatchSnapshot() - }) - - it('finds no results for an known package at an unknown version', async () => { - const fixture = new Fixture('projects/basic-project/node_modules/instrumented') - const pkgs = await findInstallingPackages( - fixture.path, - path.join(fixture.path, '..', '..'), - 'instrumented', - '0.3.0' - ) - - expect(pkgs).toMatchSnapshot() - }) -}) diff --git a/src/test/scopes/npm/find-scannable-directories.test.ts b/src/test/scopes/npm/find-scannable-directories.test.ts deleted file mode 100644 index 3cbc1cdd..00000000 --- a/src/test/scopes/npm/find-scannable-directories.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright IBM Corp. 2023, 2023 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ -import { describe, expect, it } from 'vitest' - -import { InvalidRootPathError } from '../../../main/exceptions/invalid-root-path-error.js' -import { findScannableDirectories } from '../../../main/scopes/npm/find-scannable-directories.js' -import { Fixture } from '../../__utils/fixture.js' - -describe('findScannableDirectories', () => { - it('correctly retrieves list of directories', async () => { - const rootFixture = new Fixture('projects/nested-node-modules-folders') - const fixture = new Fixture('projects/nested-node-modules-folders/node_modules/nested-dep') - - await expect(findScannableDirectories(fixture.path, rootFixture.path)).resolves.toHaveLength(2) - }) - it('returns empty if no valid dirs', async () => { - const rootFixture = new Fixture('') - const fixture = new Fixture('projects/no-node-modules-folders') - await expect(findScannableDirectories(fixture.path, rootFixture.path)).resolves.toHaveLength(0) - }) - it('throws error when cwd is not contained in the root', async () => { - const rootFixture = new Fixture('a/b/c') - const fixture = new Fixture('d/e/f') - await expect(findScannableDirectories(fixture.path, rootFixture.path)).rejects.toThrow( - InvalidRootPathError - ) - }) -}) diff --git a/src/test/scopes/npm/get-package-data.test.ts b/src/test/scopes/npm/get-package-data.test.ts index 57b34976..4a44523d 100644 --- a/src/test/scopes/npm/get-package-data.test.ts +++ b/src/test/scopes/npm/get-package-data.test.ts @@ -6,18 +6,22 @@ */ import { describe, expect, it } from 'vitest' +import { createLogFilePath } from '../../../main/core/log/create-log-file-path.js' +import { Logger } from '../../../main/core/log/logger.js' import { getPackageData } from '../../../main/scopes/npm/get-package-data.js' import { Fixture } from '../../__utils/fixture.js' +const logger = new Logger(await createLogFilePath(new Date().toISOString())) + describe('getPackageData', () => { it('correctly reads name and version', async () => { const fixture = new Fixture('projects/basic-project') - await expect(getPackageData(fixture.path)).resolves.toStrictEqual({ + await expect(getPackageData(fixture.path, logger)).resolves.toStrictEqual({ name: 'basic-project', version: '1.0.0' }) }) it('throws error for non-existant directory', async () => { - await expect(getPackageData('/made/up/directory')).rejects.toThrow(Error) + await expect(getPackageData('/made/up/directory', logger)).rejects.toThrow(Error) }) }) diff --git a/src/test/scopes/npm/get-telemetry-package-data.test.ts b/src/test/scopes/npm/get-telemetry-package-data.test.ts index 9f3272d1..74ff43b1 100644 --- a/src/test/scopes/npm/get-telemetry-package-data.test.ts +++ b/src/test/scopes/npm/get-telemetry-package-data.test.ts @@ -6,6 +6,8 @@ */ import { describe, expect, it, vi } from 'vitest' +import { createLogFilePath } from '../../../main/core/log/create-log-file-path.js' +import { Logger } from '../../../main/core/log/logger.js' import * as exec from '../../../main/core/run-command.js' import * as getPackageData from '../../../main/scopes/npm/get-package-data.js' import { getTelemetryPackageData } from '../../../main/scopes/npm/get-telemetry-package-data.js' @@ -21,9 +23,11 @@ vi.spyOn(exec, 'runCommand').mockResolvedValue({ stderr: '' }) +const logger = new Logger(await createLogFilePath(new Date().toISOString())) + describe('getTelemetryPackageData', () => { it('correctly reads name and version', async () => { - await expect(getTelemetryPackageData()).resolves.toStrictEqual({ + await expect(getTelemetryPackageData(logger)).resolves.toStrictEqual({ name: 'test-1', version: '1.0.0' }) diff --git a/src/test/scopes/npm/metrics/dependency-metric.test.ts b/src/test/scopes/npm/metrics/dependency-metric.test.ts index e5a6c6d1..ec6d514a 100644 --- a/src/test/scopes/npm/metrics/dependency-metric.test.ts +++ b/src/test/scopes/npm/metrics/dependency-metric.test.ts @@ -6,6 +6,7 @@ */ import { describe, expect, it } from 'vitest' +import { anonymize } from '../../../../main/core/anonymize.js' import { DependencyMetric } from '../../../../main/scopes/npm/metrics/dependency-metric.js' describe('dependencyMetric', () => { @@ -16,18 +17,33 @@ describe('dependencyMetric', () => { installerName: 'test-1-installer', installerVersion: '1.0.0' }).attributes - expect(attributes).toStrictEqual({ - raw: 'test-1', - owner: undefined, - name: 'test-1', - 'version.raw': '0.0.1', - 'version.major': '0', - 'version.minor': '0', - 'version.patch': '1', - 'version.preRelease': '', - 'installer.name': 'test-1-installer', - 'installer.version': '1.0.0' - }) + expect(attributes).toStrictEqual( + anonymize( + { + raw: 'test-1', + owner: undefined, + name: 'test-1', + 'version.raw': '0.0.1', + 'version.major': '0', + 'version.minor': '0', + 'version.patch': '1', + 'version.preRelease': undefined, + 'installer.name': 'test-1-installer', + 'installer.version': '1.0.0' + }, + { + hash: [ + 'raw', + 'owner', + 'name', + 'installer.name', + 'installer.version', + 'version.raw', + 'version.preRelease' + ] + } + ) + ) }) it('returns the correct attributes for a package with a prerelease', () => { @@ -37,18 +53,33 @@ describe('dependencyMetric', () => { installerName: 'test-1-installer', installerVersion: '1.0.0' }).attributes - expect(attributes).toStrictEqual({ - raw: 'test-1', - owner: undefined, - name: 'test-1', - 'installer.name': 'test-1-installer', - 'installer.version': '1.0.0', - 'version.raw': '0.0.1-rc.0', - 'version.major': '0', - 'version.minor': '0', - 'version.patch': '1', - 'version.preRelease': 'rc.0' - }) + expect(attributes).toStrictEqual( + anonymize( + { + raw: 'test-1', + owner: undefined, + name: 'test-1', + 'installer.name': 'test-1-installer', + 'installer.version': '1.0.0', + 'version.raw': '0.0.1-rc.0', + 'version.major': '0', + 'version.minor': '0', + 'version.patch': '1', + 'version.preRelease': 'rc.0' + }, + { + hash: [ + 'raw', + 'owner', + 'name', + 'installer.name', + 'installer.version', + 'version.raw', + 'version.preRelease' + ] + } + ) + ) }) it('returns the correct attributes for a package with metadata', () => { @@ -58,18 +89,33 @@ describe('dependencyMetric', () => { installerName: 'test-1-installer', installerVersion: '1.0.0' }).attributes - expect(attributes).toStrictEqual({ - raw: 'test-1', - owner: undefined, - name: 'test-1', - 'installer.name': 'test-1-installer', - 'installer.version': '1.0.0', - 'version.raw': '0.0.1+12345', - 'version.major': '0', - 'version.minor': '0', - 'version.patch': '1', - 'version.preRelease': '' - }) + expect(attributes).toStrictEqual( + anonymize( + { + raw: 'test-1', + owner: undefined, + name: 'test-1', + 'installer.name': 'test-1-installer', + 'installer.version': '1.0.0', + 'version.raw': '0.0.1+12345', + 'version.major': '0', + 'version.minor': '0', + 'version.patch': '1', + 'version.preRelease': undefined + }, + { + hash: [ + 'raw', + 'owner', + 'name', + 'installer.name', + 'installer.version', + 'version.raw', + 'version.preRelease' + ] + } + ) + ) }) it('returns the correct attributes for a package with a prerelease and metadata', () => { @@ -79,18 +125,33 @@ describe('dependencyMetric', () => { installerName: 'test-1-installer', installerVersion: '1.0.0' }).attributes - expect(attributes).toStrictEqual({ - raw: 'test-1', - owner: undefined, - name: 'test-1', - 'installer.name': 'test-1-installer', - 'installer.version': '1.0.0', - 'version.raw': '0.0.1-rc.0+12345', - 'version.major': '0', - 'version.minor': '0', - 'version.patch': '1', - 'version.preRelease': 'rc.0' - }) + expect(attributes).toStrictEqual( + anonymize( + { + raw: 'test-1', + owner: undefined, + name: 'test-1', + 'installer.name': 'test-1-installer', + 'installer.version': '1.0.0', + 'version.raw': '0.0.1-rc.0+12345', + 'version.major': '0', + 'version.minor': '0', + 'version.patch': '1', + 'version.preRelease': 'rc.0' + }, + { + hash: [ + 'raw', + 'owner', + 'name', + 'installer.name', + 'installer.version', + 'version.raw', + 'version.preRelease' + ] + } + ) + ) }) it('returns the correct attributes for a package with an owner', () => { @@ -100,17 +161,32 @@ describe('dependencyMetric', () => { installerName: 'test-1-installer', installerVersion: '1.0.0' }).attributes - expect(attributes).toStrictEqual({ - raw: '@owner/test-1', - owner: '@owner', - name: 'test-1', - 'installer.name': 'test-1-installer', - 'installer.version': '1.0.0', - 'version.raw': '0.0.1-rc.0+12345', - 'version.major': '0', - 'version.minor': '0', - 'version.patch': '1', - 'version.preRelease': 'rc.0' - }) + expect(attributes).toStrictEqual( + anonymize( + { + raw: '@owner/test-1', + owner: '@owner', + name: 'test-1', + 'installer.name': 'test-1-installer', + 'installer.version': '1.0.0', + 'version.raw': '0.0.1-rc.0+12345', + 'version.major': '0', + 'version.minor': '0', + 'version.patch': '1', + 'version.preRelease': 'rc.0' + }, + { + hash: [ + 'raw', + 'owner', + 'name', + 'installer.name', + 'installer.version', + 'version.raw', + 'version.preRelease' + ] + } + ) + ) }) }) diff --git a/src/test/scopes/npm/npm-scope.e2e.test.ts b/src/test/scopes/npm/npm-scope.e2e.test.ts index fa715365..7dbd0852 100644 --- a/src/test/scopes/npm/npm-scope.e2e.test.ts +++ b/src/test/scopes/npm/npm-scope.e2e.test.ts @@ -12,22 +12,69 @@ import { describe, expect, it } from 'vitest' import { createLogFilePath } from '../../../main/core/log/create-log-file-path.js' import { Logger } from '../../../main/core/log/logger.js' import { NpmScope } from '../../../main/scopes/npm/npm-scope.js' +import { type Schema as Config } from '../../../schemas/Schema.js' import { Fixture } from '../../__utils/fixture.js' import { initializeOtelForTest } from '../../__utils/initialize-otel-for-test.js' const logger = new Logger(await createLogFilePath(new Date().toISOString())) +const config: Config = { projectId: 'abc123', version: 1, collect: { npm: { dependencies: null } } } -describe('npmScope', () => { - it('correctly captures dependency data', async () => { - const fixture = new Fixture('projects/basic-project/node_modules/instrumented') - const scope = new NpmScope(fixture.path, path.join(fixture.path, '..', '..'), logger) +describe('class: NpmScope', () => { + describe('run', () => { + it('correctly captures dependency data', async () => { + const fixture = new Fixture('projects/basic-project/node_modules/instrumented') + const scope = new NpmScope( + fixture.path, + path.join(fixture.path, '..', '..'), + { collect: {}, projectId: '123', version: 1 }, + logger + ) - const metricReader = initializeOtelForTest() + const metricReader = initializeOtelForTest() - await scope.run() + await scope.run() - const results = await metricReader.collect() + const results = await metricReader.collect() - expect(results).toMatchSnapshot() + expect(results).toMatchSnapshot() + }) + }) + + describe('findInstallingPackages', () => { + it('correctly finds installing package data', async () => { + const fixture = new Fixture('projects/basic-project/node_modules/instrumented') + const pkgs = await new NpmScope( + fixture.path, + path.join(fixture.path, '..', '..'), + config, + logger + ).findInstallingPackages('instrumented', '0.1.0') + + expect(pkgs).toMatchSnapshot() + }) + + it('finds no results for an unknown package', async () => { + const fixture = new Fixture('projects/basic-project/node_modules/instrumented') + const pkgs = await new NpmScope( + fixture.path, + path.join(fixture.path, '..', '..'), + config, + logger + ).findInstallingPackages('not-here', '0.1.0') + + expect(pkgs).toMatchSnapshot() + }) + + it('finds no results for an known package at an unknown version', async () => { + const fixture = new Fixture('projects/basic-project/node_modules/instrumented') + const pkgs = await new NpmScope( + fixture.path, + path.join(fixture.path, '..', '..'), + config, + logger + ).findInstallingPackages('instrumented', '0.3.0') + + expect(pkgs).toMatchSnapshot() + }) }) }) diff --git a/src/test/scopes/scope-registry.test.ts b/src/test/scopes/scope-registry.test.ts new file mode 100644 index 00000000..e9f54faa --- /dev/null +++ b/src/test/scopes/scope-registry.test.ts @@ -0,0 +1,16 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { scopeRegistry } from '../../main/scopes/scope-registry.js' + +describe('scopeRegistry', () => { + it('has all scope keys defined', () => { + expect(scopeRegistry.npm).toBeDefined() + expect(scopeRegistry.jsx).not.toBeDefined() + }) +}) diff --git a/src/test/telemetry-collector.e2e.test.ts b/src/test/telemetry-collector.e2e.test.ts new file mode 100644 index 00000000..f0a8f478 --- /dev/null +++ b/src/test/telemetry-collector.e2e.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright IBM Corp. 2023, 2023 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import { describe, expect, it } from 'vitest' + +import { createLogFilePath } from '../main/core/log/create-log-file-path.js' +import { Logger } from '../main/core/log/logger.js' +import { UnknownScopeError } from '../main/exceptions/unknown-scope-error.js' +import { TelemetryCollector } from '../main/telemetry-collector.js' +import { type Schema } from '../schemas/Schema.js' + +const logger = new Logger(await createLogFilePath(new Date().toISOString())) + +describe('telemetryCollector', () => { + describe('runScopes', () => { + it('does not throw when existing scopes are specified in the config', async () => { + const telemetryCollector = new TelemetryCollector('', '', logger) + + const promises = telemetryCollector.runScopes('', '', { + projectId: 'asdf', + version: 1, + collect: { npm: { dependencies: null } } + }) + + expect(promises).toHaveLength(1) + + await Promise.allSettled(promises) + }) + }) + + it('throws when unknown scopes are encountered in the config', async () => { + const telemetryCollector = new TelemetryCollector('', '', logger) + + expect(() => + telemetryCollector.runScopes('', '', { + projectId: 'asdf', + version: 1, + collect: { notARealScope: null } + } as unknown as Schema) + ).toThrow(UnknownScopeError) + }) +}) diff --git a/vitest.config.js b/vitest.config.js index a9570bad..e8b9a83b 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -21,7 +21,7 @@ export default defineConfig({ include: ['src/main'], exclude: [ 'src/main/index.ts', - 'src/main/core/exec.ts', + 'src/main/core/run-command.ts', 'src/main/core/initialize-open-telemetry.ts', 'src/main/core/log/loggable.ts', 'src/main/core/resource-attributes.ts',