diff --git a/packages/plugin-coverage/src/lib/config.ts b/packages/plugin-coverage/src/lib/config.ts index cf4c62cf9..2b999bb4f 100644 --- a/packages/plugin-coverage/src/lib/config.ts +++ b/packages/plugin-coverage/src/lib/config.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { pluginScoreTargetsSchema } from '@code-pushup/models'; +import { ALL_COVERAGE_TYPES } from './constants.js'; export const coverageTypeSchema = z - .enum(['function', 'branch', 'line']) + .enum(ALL_COVERAGE_TYPES) .meta({ title: 'CoverageType' }); export type CoverageType = z.infer; @@ -48,7 +49,7 @@ export const coveragePluginConfigSchema = z coverageTypes: z .array(coverageTypeSchema) .min(1) - .default(['function', 'branch', 'line']) + .default([...ALL_COVERAGE_TYPES]) .meta({ description: 'Coverage types measured. Defaults to all available types.', diff --git a/packages/plugin-coverage/src/lib/constants.ts b/packages/plugin-coverage/src/lib/constants.ts new file mode 100644 index 000000000..d4dd52e3b --- /dev/null +++ b/packages/plugin-coverage/src/lib/constants.ts @@ -0,0 +1,4 @@ +export const COVERAGE_PLUGIN_SLUG = 'coverage'; +export const COVERAGE_PLUGIN_TITLE = 'Code coverage'; + +export const ALL_COVERAGE_TYPES = ['function', 'branch', 'line'] as const; diff --git a/packages/plugin-coverage/src/lib/coverage-plugin.ts b/packages/plugin-coverage/src/lib/coverage-plugin.ts index 329373575..c58bc0f01 100644 --- a/packages/plugin-coverage/src/lib/coverage-plugin.ts +++ b/packages/plugin-coverage/src/lib/coverage-plugin.ts @@ -5,12 +5,14 @@ import { type PluginConfig, validate, } from '@code-pushup/models'; -import { capitalize } from '@code-pushup/utils'; +import { logger, pluralizeToken } from '@code-pushup/utils'; import { type CoveragePluginConfig, type CoverageType, coveragePluginConfigSchema, } from './config.js'; +import { COVERAGE_PLUGIN_SLUG, COVERAGE_PLUGIN_TITLE } from './constants.js'; +import { formatMetaLog, typeToAuditSlug, typeToAuditTitle } from './format.js'; import { createRunnerFunction } from './runner/runner.js'; import { coverageDescription, coverageTypeWeightMapper } from './utils.js'; @@ -39,8 +41,8 @@ export async function coveragePlugin( const audits = coverageConfig.coverageTypes.map( (type): Audit => ({ - slug: `${type}-coverage`, - title: `${capitalize(type)} coverage`, + slug: typeToAuditSlug(type), + title: typeToAuditTitle(type), description: coverageDescription[type], }), ); @@ -58,6 +60,12 @@ export async function coveragePlugin( })), }; + logger.info( + formatMetaLog( + `Created ${pluralizeToken('audit', audits.length)} (${coverageConfig.coverageTypes.join('/')} coverage) and 1 group`, + ), + ); + const packageJson = createRequire(import.meta.url)( '../../package.json', ) as typeof import('../../package.json'); @@ -65,8 +73,8 @@ export async function coveragePlugin( const scoreTargets = coverageConfig.scoreTargets; return { - slug: 'coverage', - title: 'Code coverage', + slug: COVERAGE_PLUGIN_SLUG, + title: COVERAGE_PLUGIN_TITLE, icon: 'folder-coverage-open', description: 'Official Code PushUp code coverage plugin.', docsUrl: 'https://www.npmjs.com/package/@code-pushup/coverage-plugin/', diff --git a/packages/plugin-coverage/src/lib/format.ts b/packages/plugin-coverage/src/lib/format.ts new file mode 100644 index 000000000..a6e6b2408 --- /dev/null +++ b/packages/plugin-coverage/src/lib/format.ts @@ -0,0 +1,17 @@ +import { capitalize, pluginMetaLogFormatter } from '@code-pushup/utils'; +import type { CoverageType } from './config.js'; +import { COVERAGE_PLUGIN_TITLE } from './constants.js'; + +export const formatMetaLog = pluginMetaLogFormatter(COVERAGE_PLUGIN_TITLE); + +export function typeToAuditSlug(type: CoverageType): string { + return `${type}-coverage`; +} + +export function typeToAuditTitle(type: CoverageType): string { + return `${capitalize(type)} coverage`; +} + +export function slugToTitle(slug: string): string { + return capitalize(slug.split('-').join(' ')); +} diff --git a/packages/plugin-coverage/src/lib/nx/coverage-paths.ts b/packages/plugin-coverage/src/lib/nx/coverage-paths.ts index 12a708134..f2a5dd073 100644 --- a/packages/plugin-coverage/src/lib/nx/coverage-paths.ts +++ b/packages/plugin-coverage/src/lib/nx/coverage-paths.ts @@ -7,8 +7,15 @@ import type { import type { JestExecutorOptions } from '@nx/jest/src/executors/jest/schema'; import type { VitestExecutorOptions } from '@nx/vite/executors'; import path from 'node:path'; -import { importModule, logger, stringifyError } from '@code-pushup/utils'; +import { + importModule, + logger, + pluralize, + pluralizeToken, + stringifyError, +} from '@code-pushup/utils'; import type { CoverageResult } from '../config.js'; +import { formatMetaLog } from '../format.js'; /** * Resolves the cached project graph for the current Nx workspace. @@ -21,9 +28,8 @@ async function resolveCachedProjectGraph() { try { return readCachedProjectGraph(); } catch (error) { - logger.info( - `Could not read cached project graph, falling back to async creation. - ${stringifyError(error)}`, + logger.warn( + `Could not read cached project graph, falling back to async creation - ${stringifyError(error)}`, ); return await createProjectGraphAsync({ exitOnError: false }); } @@ -36,29 +42,52 @@ async function resolveCachedProjectGraph() { export async function getNxCoveragePaths( targets: string[] = ['test'], ): Promise { - logger.debug('💡 Gathering coverage from the following nx projects:'); - const { nodes } = await resolveCachedProjectGraph(); - const coverageResults = await Promise.all( - targets.map(async target => { - const relevantNodes = Object.values(nodes).filter(graph => - hasNxTarget(graph, target), - ); - - return await Promise.all( - relevantNodes.map>(async ({ name, data }) => { - const coveragePaths = await getCoveragePathsForTarget(data, target); - logger.debug(`- ${name}: ${target}`); - return coveragePaths; - }), - ); - }), + const coverageResultsPerTarget = Object.fromEntries( + await Promise.all( + targets.map(async (target): Promise<[string, CoverageResult[]]> => { + const relevantNodes = Object.values(nodes).filter(graph => + hasNxTarget(graph, target), + ); + + return [ + target, + await Promise.all( + relevantNodes.map(({ data }) => + getCoveragePathsForTarget(data, target), + ), + ), + ]; + }), + ), ); - logger.debug(''); + const coverageResults = Object.values(coverageResultsPerTarget).flat(); + + logger.info( + formatMetaLog( + `Inferred ${pluralizeToken('coverage report', coverageResults.length)} from Nx projects with ${pluralize('target', targets.length)} ${ + targets.length === 1 + ? targets[0] + : Object.entries(coverageResultsPerTarget) + .map(([target, results]) => `${target} (${results.length})`) + .join(' and ') + }`, + ), + ); + logger.debug( + formatMetaLog( + coverageResults + .map( + result => + `• ${typeof result === 'string' ? result : result.resultsPath}`, + ) + .join('\n'), + ), + ); - return coverageResults.flat(); + return coverageResults; } function hasNxTarget( diff --git a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts index ad9ae4ff2..3f45141cf 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts @@ -1,17 +1,23 @@ import path from 'node:path'; import type { LCOVRecord } from 'parse-lcov'; -import type { AuditOutputs } from '@code-pushup/models'; +import type { AuditOutputs, TableColumnObject } from '@code-pushup/models'; import { type FileCoverage, + capitalize, exists, + formatAsciiTable, getGitRoot, logger, objectFromEntries, objectToEntries, + pluralize, + pluralizeToken, readTextFile, toUnixNewlines, + truncatePaths, } from '@code-pushup/utils'; import type { CoverageResult, CoverageType } from '../../config.js'; +import { ALL_COVERAGE_TYPES } from '../../constants.js'; import { mergeLcovResults } from './merge-lcov.js'; import { parseLcov } from './parse-lcov.js'; import { @@ -37,6 +43,7 @@ export async function lcovResultsToAuditOutputs( // Merge multiple coverage reports for the same file const mergedResults = mergeLcovResults(lcovResults); + logMergedRecords({ before: lcovResults.length, after: mergedResults.length }); // Calculate code coverage from all coverage results const totalCoverageStats = groupLcovRecordsByCoverageType( @@ -65,37 +72,45 @@ export async function lcovResultsToAuditOutputs( export async function parseLcovFiles( results: CoverageResult[], ): Promise { - const parsedResults = ( - await Promise.all( - results.map(async result => { - const resultsPath = - typeof result === 'string' ? result : result.resultsPath; - const lcovFileContent = await readTextFile(resultsPath); - if (lcovFileContent.trim() === '') { - logger.warn(`Empty lcov report file detected at ${resultsPath}.`); - } - const parsedRecords = parseLcov(toUnixNewlines(lcovFileContent)); - return parsedRecords.map( - (record): LCOVRecord => ({ - title: record.title, - file: - typeof result === 'string' || result.pathToProject == null - ? record.file - : path.join(result.pathToProject, record.file), - functions: patchInvalidStats(record, 'functions'), - branches: patchInvalidStats(record, 'branches'), - lines: patchInvalidStats(record, 'lines'), - }), - ); - }), - ) - ).flat(); + const recordsPerReport = Object.fromEntries( + await Promise.all(results.map(parseLcovFile)), + ); + + logLcovRecords(recordsPerReport); - if (parsedResults.length === 0) { + const allRecords = Object.values(recordsPerReport).flat(); + if (allRecords.length === 0) { throw new Error('All provided coverage results are empty.'); } - return parsedResults; + return allRecords; +} + +async function parseLcovFile( + result: CoverageResult, +): Promise<[string, LCOVRecord[]]> { + const resultsPath = typeof result === 'string' ? result : result.resultsPath; + const lcovFileContent = await readTextFile(resultsPath); + if (lcovFileContent.trim() === '') { + logger.warn(`Empty LCOV report file detected at ${resultsPath}.`); + } + const parsedRecords = parseLcov(toUnixNewlines(lcovFileContent)); + logger.debug(`Parsed LCOV report file at ${resultsPath}`); + return [ + resultsPath, + parsedRecords.map( + (record): LCOVRecord => ({ + title: record.title, + file: + typeof result === 'string' || result.pathToProject == null + ? record.file + : path.join(result.pathToProject, record.file), + functions: patchInvalidStats(record, 'functions'), + branches: patchInvalidStats(record, 'branches'), + lines: patchInvalidStats(record, 'lines'), + }), + ), + ]; } /** @@ -124,7 +139,7 @@ function patchInvalidStats( */ function groupLcovRecordsByCoverageType( records: LCOVRecord[], - coverageTypes: T[], + coverageTypes: readonly T[], ): Partial> { return records.reduce>>( (acc, record) => @@ -144,7 +159,7 @@ function groupLcovRecordsByCoverageType( */ function getCoverageStatsFromLcovRecord( record: LCOVRecord, - coverageTypes: T[], + coverageTypes: readonly T[], ): Record { return objectFromEntries( coverageTypes.map(coverageType => [ @@ -153,3 +168,89 @@ function getCoverageStatsFromLcovRecord( ]), ); } + +function logLcovRecords(recordsPerReport: Record): void { + const reportPaths = Object.keys(recordsPerReport); + const reportsCount = reportPaths.length; + const sourceFilesCount = new Set( + Object.values(recordsPerReport) + .flat() + .map(record => record.file), + ).size; + logger.info( + `Parsed ${pluralizeToken('LCOV report', reportsCount)}, coverage collected from ${pluralizeToken('source file', sourceFilesCount)}`, + ); + + if (!logger.isVerbose()) { + return; + } + + const truncatedPaths = truncatePaths(reportPaths); + + logger.newline(); + logger.debug( + formatAsciiTable({ + columns: [ + { key: 'report', label: 'LCOV report', align: 'left' }, + { key: 'filesCount', label: 'Files', align: 'right' }, + ...ALL_COVERAGE_TYPES.map( + (type): TableColumnObject => ({ + key: type, + label: capitalize(pluralize(type)), + align: 'right', + }), + ), + ], + rows: Object.entries(recordsPerReport).map( + ([reportPath, records], idx) => { + const groups = groupLcovRecordsByCoverageType( + records, + ALL_COVERAGE_TYPES, + ); + const stats: Record = objectFromEntries( + objectToEntries(groups).map(([type, files = []]) => [ + type, + formatCoverageSum(files), + ]), + ); + const report = truncatedPaths[idx] ?? reportPath; + return { report, filesCount: records.length, ...stats }; + }, + ), + }), + ); + logger.newline(); +} + +function formatCoverageSum(files: FileCoverage[]): string { + const { covered, total } = files.reduce< + Pick + >( + (acc, file) => ({ + covered: acc.covered + file.covered, + total: acc.total + file.total, + }), + { covered: 0, total: 0 }, + ); + + if (total === 0) { + return 'n/a'; + } + + const percentage = (covered / total) * 100; + return `${percentage.toFixed(1)}%`; +} + +function logMergedRecords(counts: { before: number; after: number }): void { + if (counts.before === counts.after) { + logger.debug( + counts.after === 1 + ? 'There is only 1 LCOV record' // should be rare + : `All of ${pluralizeToken('LCOV record', counts.after)} have unique source files`, + ); + } else { + logger.info( + `Merged ${counts.before} into ${pluralizeToken('unique LCOV record', counts.after)} per source file`, + ); + } +} diff --git a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts index 49c40959f..ffc970f10 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts @@ -145,7 +145,7 @@ end_of_record ]); expect(logger.warn).toHaveBeenCalledWith( - `Empty lcov report file detected at ${path.join( + `Empty LCOV report file detected at ${path.join( 'coverage', 'lcov.info', )}.`, diff --git a/packages/plugin-coverage/src/lib/runner/runner.ts b/packages/plugin-coverage/src/lib/runner/runner.ts index 09d988a78..4e604178d 100644 --- a/packages/plugin-coverage/src/lib/runner/runner.ts +++ b/packages/plugin-coverage/src/lib/runner/runner.ts @@ -1,6 +1,12 @@ -import type { RunnerFunction } from '@code-pushup/models'; -import { executeProcess } from '@code-pushup/utils'; +import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; +import { + executeProcess, + formatAsciiTable, + logger, + pluralizeToken, +} from '@code-pushup/utils'; import type { FinalCoveragePluginConfig } from '../config.js'; +import { slugToTitle } from '../format.js'; import { lcovResultsToAuditOutputs } from './lcov/lcov-runner.js'; export function createRunnerFunction( @@ -15,20 +21,50 @@ export function createRunnerFunction( } = config; // Run coverage tool if provided - if (coverageToolCommand != null) { + if (coverageToolCommand == null) { + logger.info( + 'No test command provided, assuming coverage has already been collected', + ); + } else { + logger.info('Executing test command to collect coverage ...'); const { command, args } = coverageToolCommand; try { await executeProcess({ command, args }); } catch { if (!continueOnCommandFail) { throw new Error( - 'Coverage plugin: Running coverage tool failed. Make sure all your provided tests are passing.', + 'Running coverage tool failed. Make sure all your tests are passing.', ); } } } // Calculate coverage from LCOV results - return lcovResultsToAuditOutputs(reports, coverageTypes); + const auditOutputs = await lcovResultsToAuditOutputs( + reports, + coverageTypes, + ); + + logAuditOutputs(auditOutputs); + + return auditOutputs; }; } + +function logAuditOutputs(auditOutputs: AuditOutputs): void { + logger.info( + `Transformed LCOV reports to ${pluralizeToken('audit output', auditOutputs.length)}`, + ); + logger.info( + formatAsciiTable( + { + columns: ['left', 'right'], + rows: auditOutputs.map(audit => [ + `• ${slugToTitle(audit.slug)}`, + `${audit.value.toFixed(2)}%`, + ]), + }, + { borderless: true }, + ), + ); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 05be1d275..0adcf418b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -43,6 +43,7 @@ export { readJsonFile, readTextFile, removeDirectoryIfExists, + truncatePaths, type CrawlFileSystemOptions, } from './lib/file-system.js'; export { filterItemRefsBy } from './lib/filter.js'; diff --git a/packages/utils/src/lib/file-system.ts b/packages/utils/src/lib/file-system.ts index 71db43c6a..5956dbbff 100644 --- a/packages/utils/src/lib/file-system.ts +++ b/packages/utils/src/lib/file-system.ts @@ -180,3 +180,46 @@ export function splitFilePath(filePath: string): SplitFilePath { } return { folders, file }; } + +export function truncatePaths(paths: string[]): string[] { + const segmentedPaths = paths + .map(splitFilePath) + .map(({ folders, file }): string[] => [...folders, file]); + + const first = segmentedPaths[0]; + const others = segmentedPaths.slice(1); + if (!first) { + return paths; + } + + /* eslint-disable functional/no-let,functional/no-loop-statements,unicorn/no-for-loop */ + let offsetLeft = 0; + let offsetRight = 0; + for (let left = 0; left < first.length; left++) { + if (others.every(segments => segments[left] === first[left])) { + offsetLeft++; + } else { + break; + } + } + for (let right = 1; right <= first.length; right++) { + if (others.every(segments => segments.at(-right) === first.at(-right))) { + offsetRight++; + } else { + break; + } + } + /* eslint-enable functional/no-let,functional/no-loop-statements,unicorn/no-for-loop */ + + return segmentedPaths.map(segments => { + const uniqueSegments = segments.slice( + offsetLeft, + offsetRight > 0 ? -offsetRight : undefined, + ); + return path.join( + offsetLeft > 0 ? '…' : '', + ...uniqueSegments, + offsetRight > 0 ? '…' : '', + ); + }); +} diff --git a/packages/utils/src/lib/file-system.unit.test.ts b/packages/utils/src/lib/file-system.unit.test.ts index 515b3091a..0652ee389 100644 --- a/packages/utils/src/lib/file-system.unit.test.ts +++ b/packages/utils/src/lib/file-system.unit.test.ts @@ -12,6 +12,7 @@ import { findNearestFile, projectToFilename, splitFilePath, + truncatePaths, } from './file-system.js'; describe('ensureDirectoryExists', () => { @@ -268,3 +269,60 @@ describe('splitFilePath', () => { }); }); }); + +describe('truncatePaths', () => { + it('should replace shared path prefix with ellipsis', () => { + expect( + truncatePaths([ + path.join('dist', 'packages', 'cli'), + path.join('dist', 'packages', 'core'), + path.join('dist', 'packages', 'utils'), + ]), + ).toEqual([ + path.join('…', 'cli'), + path.join('…', 'core'), + path.join('…', 'utils'), + ]); + }); + + it('should replace shared path suffix with ellipsis', () => { + expect( + truncatePaths([ + path.join('e2e', 'cli-e2e', 'coverage', 'lcov.info'), + path.join('packages', 'cli', 'coverage', 'lcov.info'), + path.join('packages', 'core', 'coverage', 'lcov.info'), + ]), + ).toEqual([ + path.join('e2e', 'cli-e2e', '…'), + path.join('packages', 'cli', '…'), + path.join('packages', 'core', '…'), + ]); + }); + + it('should replace shared path prefix and suffix at once', () => { + expect( + truncatePaths([ + path.join('coverage', 'packages', 'cli', 'int-tests', 'lcov.info'), + path.join('coverage', 'packages', 'cli', 'unit-tests', 'lcov.info'), + path.join('coverage', 'packages', 'core', 'int-tests', 'lcov.info'), + path.join('coverage', 'packages', 'core', 'unit-tests', 'lcov.info'), + path.join('coverage', 'packages', 'utils', 'unit-tests', 'lcov.info'), + ]), + ).toEqual([ + path.join('…', 'cli', 'int-tests', '…'), + path.join('…', 'cli', 'unit-tests', '…'), + path.join('…', 'core', 'int-tests', '…'), + path.join('…', 'core', 'unit-tests', '…'), + path.join('…', 'utils', 'unit-tests', '…'), + ]); + }); + + it('should leave unique paths unchanged', () => { + const paths = [ + path.join('e2e', 'cli-e2e'), + path.join('packages', 'cli'), + path.join('packages', 'core'), + ]; + expect(truncatePaths(paths)).toEqual(paths); + }); +}); diff --git a/packages/utils/src/lib/formatting.ts b/packages/utils/src/lib/formatting.ts index b4ab00bb4..95a410cf3 100644 --- a/packages/utils/src/lib/formatting.ts +++ b/packages/utils/src/lib/formatting.ts @@ -26,10 +26,14 @@ export function pluralize(text: string, amount?: number): string { return text; } + // best approximation of English pluralization "rules" + // https://www.grammarly.com/blog/grammar/spelling-plurals-with-s-es/ + if (text.endsWith('y')) { return `${text.slice(0, -1)}ies`; } - if (text.endsWith('s')) { + const suffixes = ['s', 'sh', 'ch', 'x', 'z']; + if (suffixes.some(suffix => text.endsWith(suffix))) { return `${text}es`; } return `${text}s`; diff --git a/packages/utils/src/lib/formatting.unit.test.ts b/packages/utils/src/lib/formatting.unit.test.ts index 1552949d1..66bc826c8 100644 --- a/packages/utils/src/lib/formatting.unit.test.ts +++ b/packages/utils/src/lib/formatting.unit.test.ts @@ -63,6 +63,7 @@ describe('pluralize', () => { ['error', 'errors'], ['category', 'categories'], ['status', 'statuses'], + ['branch', 'branches'], ])('should pluralize "%s" as "%s"', (singular, plural) => { expect(pluralize(singular)).toBe(plural); });