11import path from 'node:path' ;
22import type { LCOVRecord } from 'parse-lcov' ;
3- import type { AuditOutputs } from '@code-pushup/models' ;
3+ import type { AuditOutputs , TableColumnObject } from '@code-pushup/models' ;
44import {
55 type FileCoverage ,
6+ capitalize ,
67 exists ,
8+ formatAsciiTable ,
79 getGitRoot ,
810 logger ,
911 objectFromEntries ,
1012 objectToEntries ,
13+ pluralize ,
14+ pluralizeToken ,
1115 readTextFile ,
1216 toUnixNewlines ,
1317} from '@code-pushup/utils' ;
1418import type { CoverageResult , CoverageType } from '../../config.js' ;
19+ import { ALL_COVERAGE_TYPES } from '../../constants.js' ;
1520import { mergeLcovResults } from './merge-lcov.js' ;
1621import { parseLcov } from './parse-lcov.js' ;
1722import {
@@ -37,6 +42,7 @@ export async function lcovResultsToAuditOutputs(
3742
3843 // Merge multiple coverage reports for the same file
3944 const mergedResults = mergeLcovResults ( lcovResults ) ;
45+ logMergedRecords ( { before : lcovResults . length , after : mergedResults . length } ) ;
4046
4147 // Calculate code coverage from all coverage results
4248 const totalCoverageStats = groupLcovRecordsByCoverageType (
@@ -65,37 +71,45 @@ export async function lcovResultsToAuditOutputs(
6571export async function parseLcovFiles (
6672 results : CoverageResult [ ] ,
6773) : Promise < LCOVRecord [ ] > {
68- const parsedResults = (
69- await Promise . all (
70- results . map ( async result => {
71- const resultsPath =
72- typeof result === 'string' ? result : result . resultsPath ;
73- const lcovFileContent = await readTextFile ( resultsPath ) ;
74- if ( lcovFileContent . trim ( ) === '' ) {
75- logger . warn ( `Empty lcov report file detected at ${ resultsPath } .` ) ;
76- }
77- const parsedRecords = parseLcov ( toUnixNewlines ( lcovFileContent ) ) ;
78- return parsedRecords . map (
79- ( record ) : LCOVRecord => ( {
80- title : record . title ,
81- file :
82- typeof result === 'string' || result . pathToProject == null
83- ? record . file
84- : path . join ( result . pathToProject , record . file ) ,
85- functions : patchInvalidStats ( record , 'functions' ) ,
86- branches : patchInvalidStats ( record , 'branches' ) ,
87- lines : patchInvalidStats ( record , 'lines' ) ,
88- } ) ,
89- ) ;
90- } ) ,
91- )
92- ) . flat ( ) ;
74+ const recordsPerReport = Object . fromEntries (
75+ await Promise . all ( results . map ( parseLcovFile ) ) ,
76+ ) ;
9377
94- if ( parsedResults . length === 0 ) {
78+ logLcovRecords ( recordsPerReport ) ;
79+
80+ const allRecords = Object . values ( recordsPerReport ) . flat ( ) ;
81+ if ( allRecords . length === 0 ) {
9582 throw new Error ( 'All provided coverage results are empty.' ) ;
9683 }
9784
98- return parsedResults ;
85+ return allRecords ;
86+ }
87+
88+ async function parseLcovFile (
89+ result : CoverageResult ,
90+ ) : Promise < [ string , LCOVRecord [ ] ] > {
91+ const resultsPath = typeof result === 'string' ? result : result . resultsPath ;
92+ const lcovFileContent = await readTextFile ( resultsPath ) ;
93+ if ( lcovFileContent . trim ( ) === '' ) {
94+ logger . warn ( `Empty LCOV report file detected at ${ resultsPath } .` ) ;
95+ }
96+ const parsedRecords = parseLcov ( toUnixNewlines ( lcovFileContent ) ) ;
97+ logger . debug ( `Parsed LCOV report file at ${ resultsPath } ` ) ;
98+ return [
99+ resultsPath ,
100+ parsedRecords . map (
101+ ( record ) : LCOVRecord => ( {
102+ title : record . title ,
103+ file :
104+ typeof result === 'string' || result . pathToProject == null
105+ ? record . file
106+ : path . join ( result . pathToProject , record . file ) ,
107+ functions : patchInvalidStats ( record , 'functions' ) ,
108+ branches : patchInvalidStats ( record , 'branches' ) ,
109+ lines : patchInvalidStats ( record , 'lines' ) ,
110+ } ) ,
111+ ) ,
112+ ] ;
99113}
100114
101115/**
@@ -124,7 +138,7 @@ function patchInvalidStats<T extends 'branches' | 'functions' | 'lines'>(
124138 */
125139function groupLcovRecordsByCoverageType < T extends CoverageType > (
126140 records : LCOVRecord [ ] ,
127- coverageTypes : T [ ] ,
141+ coverageTypes : readonly T [ ] ,
128142) : Partial < Record < T , FileCoverage [ ] > > {
129143 return records . reduce < Partial < Record < T , FileCoverage [ ] > > > (
130144 ( acc , record ) =>
@@ -144,7 +158,7 @@ function groupLcovRecordsByCoverageType<T extends CoverageType>(
144158 */
145159function getCoverageStatsFromLcovRecord < T extends CoverageType > (
146160 record : LCOVRecord ,
147- coverageTypes : T [ ] ,
161+ coverageTypes : readonly T [ ] ,
148162) : Record < T , FileCoverage > {
149163 return objectFromEntries (
150164 coverageTypes . map ( coverageType => [
@@ -153,3 +167,80 @@ function getCoverageStatsFromLcovRecord<T extends CoverageType>(
153167 ] ) ,
154168 ) ;
155169}
170+
171+ function logLcovRecords ( recordsPerReport : Record < string , LCOVRecord [ ] > ) : void {
172+ const reportsCount = Object . keys ( recordsPerReport ) . length ;
173+ const sourceFilesCount = new Set (
174+ Object . values ( recordsPerReport )
175+ . flat ( )
176+ . map ( record => record . file ) ,
177+ ) . size ;
178+ logger . info (
179+ `Parsed ${ pluralizeToken ( 'LCOV report' , reportsCount ) } , coverage collected from ${ pluralizeToken ( 'source file' , sourceFilesCount ) } ` ,
180+ ) ;
181+
182+ if ( ! logger . isVerbose ( ) ) {
183+ return ;
184+ }
185+
186+ logger . newline ( ) ;
187+ logger . debug (
188+ formatAsciiTable ( {
189+ columns : [
190+ { key : 'reportPath' , label : 'LCOV report' , align : 'left' } ,
191+ { key : 'filesCount' , label : 'Files' , align : 'right' } ,
192+ ...ALL_COVERAGE_TYPES . map (
193+ ( type ) : TableColumnObject => ( {
194+ key : type ,
195+ label : capitalize ( pluralize ( type ) ) ,
196+ align : 'right' ,
197+ } ) ,
198+ ) ,
199+ ] ,
200+ // TODO: truncate report paths (replace shared segments with ellipsis)
201+ rows : Object . entries ( recordsPerReport ) . map ( ( [ reportPath , records ] ) => {
202+ const groups = groupLcovRecordsByCoverageType (
203+ records ,
204+ ALL_COVERAGE_TYPES ,
205+ ) ;
206+ const stats : Record < CoverageType , string > = objectFromEntries (
207+ objectToEntries ( groups ) . map ( ( [ type , files = [ ] ] ) => [
208+ type ,
209+ formatCoverageSum ( files ) ,
210+ ] ) ,
211+ ) ;
212+ return { reportPath, filesCount : records . length , ...stats } ;
213+ } ) ,
214+ } ) ,
215+ ) ;
216+ logger . newline ( ) ;
217+ }
218+
219+ function formatCoverageSum ( files : FileCoverage [ ] ) : string {
220+ const { covered, total } = files . reduce <
221+ Pick < FileCoverage , 'covered' | 'total' >
222+ > (
223+ ( acc , file ) => ( {
224+ covered : acc . covered + file . covered ,
225+ total : acc . total + file . total ,
226+ } ) ,
227+ { covered : 0 , total : 0 } ,
228+ ) ;
229+
230+ const percentage = ( covered / total ) * 100 ;
231+ return `${ percentage . toFixed ( 1 ) } %` ;
232+ }
233+
234+ function logMergedRecords ( counts : { before : number ; after : number } ) : void {
235+ if ( counts . before === counts . after ) {
236+ logger . debug (
237+ counts . after === 1
238+ ? 'There is only 1 LCOV record' // should be rare
239+ : `All of ${ pluralizeToken ( 'LCOV record' , counts . after ) } have unique source files` ,
240+ ) ;
241+ } else {
242+ logger . info (
243+ `Merged ${ counts . before } into ${ pluralizeToken ( 'unique LCOV record' , counts . after ) } per source file` ,
244+ ) ;
245+ }
246+ }
0 commit comments