Skip to content

Commit 639a447

Browse files
committed
feat(plugin-coverage): log runner steps and statistics
1 parent 2eb0cc8 commit 639a447

File tree

7 files changed

+186
-43
lines changed

7 files changed

+186
-43
lines changed

packages/plugin-coverage/src/lib/config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { z } from 'zod';
22
import { pluginScoreTargetsSchema } from '@code-pushup/models';
3+
import { ALL_COVERAGE_TYPES } from './constants.js';
34

45
export const coverageTypeSchema = z
5-
.enum(['function', 'branch', 'line'])
6+
.enum(ALL_COVERAGE_TYPES)
67
.meta({ title: 'CoverageType' });
78
export type CoverageType = z.infer<typeof coverageTypeSchema>;
89

@@ -48,7 +49,7 @@ export const coveragePluginConfigSchema = z
4849
coverageTypes: z
4950
.array(coverageTypeSchema)
5051
.min(1)
51-
.default(['function', 'branch', 'line'])
52+
.default([...ALL_COVERAGE_TYPES])
5253
.meta({
5354
description:
5455
'Coverage types measured. Defaults to all available types.',
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export const COVERAGE_PLUGIN_SLUG = 'coverage';
22
export const COVERAGE_PLUGIN_TITLE = 'Code coverage';
3+
4+
export const ALL_COVERAGE_TYPES = ['function', 'branch', 'line'] as const;

packages/plugin-coverage/src/lib/coverage-plugin.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import {
55
type PluginConfig,
66
validate,
77
} from '@code-pushup/models';
8-
import { capitalize, logger, pluralizeToken } from '@code-pushup/utils';
8+
import { logger, pluralizeToken } from '@code-pushup/utils';
99
import {
1010
type CoveragePluginConfig,
1111
type CoverageType,
1212
coveragePluginConfigSchema,
1313
} from './config.js';
1414
import { COVERAGE_PLUGIN_SLUG, COVERAGE_PLUGIN_TITLE } from './constants.js';
15-
import { formatMetaLog } from './format.js';
15+
import { formatMetaLog, typeToAuditSlug, typeToAuditTitle } from './format.js';
1616
import { createRunnerFunction } from './runner/runner.js';
1717
import { coverageDescription, coverageTypeWeightMapper } from './utils.js';
1818

@@ -41,8 +41,8 @@ export async function coveragePlugin(
4141

4242
const audits = coverageConfig.coverageTypes.map(
4343
(type): Audit => ({
44-
slug: `${type}-coverage`,
45-
title: `${capitalize(type)} coverage`,
44+
slug: typeToAuditSlug(type),
45+
title: typeToAuditTitle(type),
4646
description: coverageDescription[type],
4747
}),
4848
);
Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { pluginMetaLogFormatter } from '@code-pushup/utils';
1+
import { capitalize, pluginMetaLogFormatter } from '@code-pushup/utils';
2+
import type { CoverageType } from './config.js';
23
import { COVERAGE_PLUGIN_TITLE } from './constants.js';
34

45
export const formatMetaLog = pluginMetaLogFormatter(COVERAGE_PLUGIN_TITLE);
6+
7+
export function typeToAuditSlug(type: CoverageType): string {
8+
return `${type}-coverage`;
9+
}
10+
11+
export function typeToAuditTitle(type: CoverageType): string {
12+
return `${capitalize(type)} coverage`;
13+
}
14+
15+
export function slugToTitle(slug: string): string {
16+
return capitalize(slug.split('-').join(' '));
17+
}

packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts

Lines changed: 121 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import path from 'node:path';
22
import type { LCOVRecord } from 'parse-lcov';
3-
import type { AuditOutputs } from '@code-pushup/models';
3+
import type { AuditOutputs, TableColumnObject } from '@code-pushup/models';
44
import {
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';
1418
import type { CoverageResult, CoverageType } from '../../config.js';
19+
import { ALL_COVERAGE_TYPES } from '../../constants.js';
1520
import { mergeLcovResults } from './merge-lcov.js';
1621
import { parseLcov } from './parse-lcov.js';
1722
import {
@@ -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(
6571
export 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
*/
125139
function 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
*/
145159
function 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+
}

packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ end_of_record
145145
]);
146146

147147
expect(logger.warn).toHaveBeenCalledWith(
148-
`Empty lcov report file detected at ${path.join(
148+
`Empty LCOV report file detected at ${path.join(
149149
'coverage',
150150
'lcov.info',
151151
)}.`,

packages/plugin-coverage/src/lib/runner/runner.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import type { RunnerFunction } from '@code-pushup/models';
2-
import { executeProcess } from '@code-pushup/utils';
1+
import type { AuditOutputs, RunnerFunction } from '@code-pushup/models';
2+
import {
3+
executeProcess,
4+
formatAsciiTable,
5+
logger,
6+
pluralizeToken,
7+
} from '@code-pushup/utils';
38
import type { FinalCoveragePluginConfig } from '../config.js';
9+
import { slugToTitle } from '../format.js';
410
import { lcovResultsToAuditOutputs } from './lcov/lcov-runner.js';
511

612
export function createRunnerFunction(
@@ -15,20 +21,50 @@ export function createRunnerFunction(
1521
} = config;
1622

1723
// Run coverage tool if provided
18-
if (coverageToolCommand != null) {
24+
if (coverageToolCommand == null) {
25+
logger.info(
26+
'No test command provided, assuming coverage has already been collected',
27+
);
28+
} else {
29+
logger.info('Executing test command to collect coverage ...');
1930
const { command, args } = coverageToolCommand;
2031
try {
2132
await executeProcess({ command, args });
2233
} catch {
2334
if (!continueOnCommandFail) {
2435
throw new Error(
25-
'Coverage plugin: Running coverage tool failed. Make sure all your provided tests are passing.',
36+
'Running coverage tool failed. Make sure all your tests are passing.',
2637
);
2738
}
2839
}
2940
}
3041

3142
// Calculate coverage from LCOV results
32-
return lcovResultsToAuditOutputs(reports, coverageTypes);
43+
const auditOutputs = await lcovResultsToAuditOutputs(
44+
reports,
45+
coverageTypes,
46+
);
47+
48+
logAuditOutputs(auditOutputs);
49+
50+
return auditOutputs;
3351
};
3452
}
53+
54+
function logAuditOutputs(auditOutputs: AuditOutputs): void {
55+
logger.info(
56+
`Transformed LCOV reports to ${pluralizeToken('audit output', auditOutputs.length)}`,
57+
);
58+
logger.info(
59+
formatAsciiTable(
60+
{
61+
columns: ['left', 'right'],
62+
rows: auditOutputs.map(audit => [
63+
`• ${slugToTitle(audit.slug)}`,
64+
`${audit.value.toFixed(2)}%`,
65+
]),
66+
},
67+
{ borderless: true },
68+
),
69+
);
70+
}

0 commit comments

Comments
 (0)