From 4332b2af4f4b44c4ea6d1914fb1c56cb9bbf00df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Katka=20Pil=C3=A1tov=C3=A1?= <katerina.pilatova@flowup.cz> Date: Thu, 30 May 2024 11:17:03 +0200 Subject: [PATCH] fix(plugin-coverage): merge multiple results for a file (#688) --- packages/plugin-coverage/README.md | 1 + .../src/lib/runner/lcov/lcov-runner.ts | 6 +- .../src/lib/runner/lcov/merge-lcov.ts | 180 +++++++++++ .../lib/runner/lcov/merge-lcov.unit.test.ts | 299 ++++++++++++++++++ 4 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.ts create mode 100644 packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.unit.test.ts diff --git a/packages/plugin-coverage/README.md b/packages/plugin-coverage/README.md index 64420feed..468d6fd91 100644 --- a/packages/plugin-coverage/README.md +++ b/packages/plugin-coverage/README.md @@ -7,6 +7,7 @@ 🧪 **Code PushUp plugin for tracking code coverage.** ☂️ This plugin allows you to measure and track code coverage on your project. +It accepts the LCOV coverage format and merges coverage results from any test suites provided. Measured coverage types are mapped to Code PushUp audits in the following way 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 25af2b1ec..1f05ce705 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.ts @@ -3,6 +3,7 @@ import type { LCOVRecord } from 'parse-lcov'; import { AuditOutputs } from '@code-pushup/models'; import { exists, readTextFile, toUnixNewlines } from '@code-pushup/utils'; import { CoverageResult, CoverageType } from '../../config'; +import { mergeLcovResults } from './merge-lcov'; import { parseLcov } from './parse-lcov'; import { lcovCoverageToAuditOutput, @@ -26,9 +27,12 @@ export async function lcovResultsToAuditOutputs( // Parse lcov files const lcovResults = await parseLcovFiles(results); + // Merge multiple coverage reports for the same file + const mergedResults = mergeLcovResults(lcovResults); + // Calculate code coverage from all coverage results const totalCoverageStats = getTotalCoverageFromLcovRecords( - lcovResults, + mergedResults, coverageTypes, ); diff --git a/packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.ts b/packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.ts new file mode 100644 index 000000000..112b0047a --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.ts @@ -0,0 +1,180 @@ +import { + BranchesDetails, + FunctionsDetails, + LCOVRecord, + LinesDetails, +} from 'parse-lcov'; + +export function mergeLcovResults(records: LCOVRecord[]): LCOVRecord[] { + // Skip if there are no files with multiple records + const allFilenames = records.map(record => record.file); + if (allFilenames.length === new Set(allFilenames).size) { + return records; + } + + return records.reduce<LCOVRecord[]>((accMerged, currRecord, currIndex) => { + const filePath = currRecord.file; + const lines = currRecord.lines.found; + + const duplicates = records.reduce<[LCOVRecord, number][]>( + (acc, candidateRecord, candidateIndex) => { + if ( + candidateRecord.file === filePath && + candidateRecord.lines.found === lines && + candidateIndex !== currIndex + ) { + return [...acc, [candidateRecord, candidateIndex]]; + } + return acc; + }, + [], + ); + + // This is not the first time the record has been identified as a duplicate + if ( + duplicates.map(duplicate => duplicate[1]).some(index => index < currIndex) + ) { + return accMerged; + } + + // Unique record + if (duplicates.length === 0) { + return [...accMerged, currRecord]; + } + + return [ + ...accMerged, + mergeDuplicateLcovRecords([ + currRecord, + ...duplicates.map(duplicate => duplicate[0]), + ]), + ]; + }, []); +} + +export function mergeDuplicateLcovRecords(records: LCOVRecord[]): LCOVRecord { + const linesDetails = mergeLcovLineDetails( + records.map(record => record.lines.details), + ); + const linesHit = linesDetails.reduce( + (acc, line) => acc + (line.hit > 0 ? 1 : 0), + 0, + ); + + const branchesDetails = mergeLcovBranchesDetails( + records.map(record => record.branches.details), + ); + const branchesHit = branchesDetails.reduce( + (acc, branch) => acc + (branch.taken > 0 ? 1 : 0), + 0, + ); + + const functionsDetails = mergeLcovFunctionsDetails( + records.map(record => record.functions.details), + ); + + const functionsHit = functionsDetails.reduce( + (acc, func) => acc + (func.hit != null && func.hit > 0 ? 1 : 0), + 0, + ); + + const mergedRecord: LCOVRecord = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: records[0]!.file, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + title: records[0]!.title, + lines: { + found: linesDetails.length, + hit: linesHit, + details: linesDetails, + }, + branches: { + found: branchesDetails.length, + hit: branchesHit, + details: branchesDetails, + }, + functions: { + found: functionsDetails.length, + hit: functionsHit, + details: functionsDetails, + }, + }; + return mergedRecord; +} + +export function mergeLcovLineDetails( + details: LinesDetails[][], +): LinesDetails[] { + const flatDetails = details.flat(); + + const uniqueLines = [ + ...new Set(flatDetails.map(flatDetail => flatDetail.line)), + ]; + + return uniqueLines.map(line => { + const hitSum = flatDetails + .filter(lineDetail => lineDetail.line === line) + .reduce((acc, lineDetail) => acc + lineDetail.hit, 0); + + return { line, hit: hitSum }; + }); +} + +export function mergeLcovBranchesDetails( + details: BranchesDetails[][], +): BranchesDetails[] { + const flatDetails = details.flat(); + + const uniqueBranches = [ + ...new Set( + flatDetails.map(({ line, block, branch }) => + JSON.stringify({ line, block, branch }), + ), + ), + ].map( + functionJSON => + JSON.parse(functionJSON) as Pick< + BranchesDetails, + 'line' | 'block' | 'branch' + >, + ); + + return uniqueBranches.map(({ line, block, branch }) => { + const takenSum = flatDetails + .filter( + branchDetail => + branchDetail.line === line && + branchDetail.block === block && + branchDetail.branch === branch, + ) + .reduce((acc, branchDetail) => acc + branchDetail.taken, 0); + + return { line, block, branch, taken: takenSum }; + }); +} + +export function mergeLcovFunctionsDetails( + details: FunctionsDetails[][], +): FunctionsDetails[] { + const flatDetails = details.flat(); + + const uniqueFunctions = [ + ...new Set( + flatDetails.map(({ line, name }) => JSON.stringify({ line, name })), + ), + ].map( + functionJSON => + JSON.parse(functionJSON) as Pick<FunctionsDetails, 'line' | 'name'>, + ); + + return uniqueFunctions.map(({ line, name }) => { + const hitSum = flatDetails + .filter( + functionDetail => + functionDetail.line === line && functionDetail.name === name, + ) + .reduce((acc, functionDetail) => acc + (functionDetail.hit ?? 0), 0); + + return { line, name, hit: hitSum }; + }); +} diff --git a/packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.unit.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.unit.test.ts new file mode 100644 index 000000000..6ca2b9a33 --- /dev/null +++ b/packages/plugin-coverage/src/lib/runner/lcov/merge-lcov.unit.test.ts @@ -0,0 +1,299 @@ +import { + BranchesDetails, + FunctionsDetails, + LCOVRecord, + LinesDetails, +} from 'parse-lcov'; +import { describe, expect, it } from 'vitest'; +import { + mergeDuplicateLcovRecords, + mergeLcovBranchesDetails, + mergeLcovFunctionsDetails, + mergeLcovLineDetails, + mergeLcovResults, +} from './merge-lcov'; + +describe('mergeLcovResults', () => { + it('should merge duplicates and keep unique reports', () => { + const UNIQUE_REPORT = { + title: '', + file: 'src/index.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { found: 1, hit: 0, details: [{ line: 1, hit: 0 }] }, + functions: { + found: 1, + hit: 1, + details: [{ line: 1, name: 'sum', hit: 3 }], + }, + }; + + expect( + mergeLcovResults([ + { + title: '', + file: 'src/commands.ts', + branches: { + found: 1, + hit: 1, + details: [{ line: 1, block: 1, branch: 0, taken: 2 }], + }, + lines: { + found: 2, + hit: 2, + details: [ + { line: 1, hit: 2 }, + { line: 2, hit: 1 }, + ], + }, + functions: { found: 0, hit: 0, details: [] }, + }, + UNIQUE_REPORT, + { + title: '', + file: 'src/commands.ts', + branches: { + found: 1, + hit: 1, + details: [{ line: 1, block: 1, branch: 0, taken: 0 }], + }, + lines: { + found: 2, + hit: 1, + details: [ + { line: 1, hit: 0 }, + { line: 3, hit: 3 }, + ], + }, + functions: { found: 0, hit: 0, details: [] }, + }, + ]), + ).toStrictEqual<LCOVRecord[]>([ + { + title: '', + file: 'src/commands.ts', + branches: { + found: 1, + hit: 1, + details: [{ line: 1, block: 1, branch: 0, taken: 2 }], + }, + lines: { + found: 3, + hit: 3, + details: [ + { line: 1, hit: 2 }, + { line: 2, hit: 1 }, + { line: 3, hit: 3 }, + ], + }, + functions: { found: 0, hit: 0, details: [] }, + }, + UNIQUE_REPORT, + ]); + }); +}); + +describe('mergeDuplicateLcovRecords', () => { + it('should merge multiple records', () => { + expect( + mergeDuplicateLcovRecords([ + { + title: '', + file: 'src/commands.ts', + branches: { + found: 2, + hit: 1, + details: [ + { line: 1, block: 1, branch: 0, taken: 2 }, + { line: 1, block: 1, branch: 1, taken: 0 }, + ], + }, + lines: { + found: 3, + hit: 2, + details: [ + { line: 1, hit: 2 }, + { line: 2, hit: 1 }, + { line: 3, hit: 0 }, + ], + }, + functions: { + found: 1, + hit: 0, + details: [{ line: 1, name: 'sum', hit: 0 }], + }, + }, + { + title: '', + file: 'src/commands.ts', + branches: { + found: 2, + hit: 1, + details: [ + { line: 1, block: 1, branch: 0, taken: 0 }, + { line: 1, block: 1, branch: 1, taken: 1 }, + ], + }, + lines: { + found: 3, + hit: 2, + details: [ + { line: 1, hit: 0 }, + { line: 2, hit: 1 }, + { line: 3, hit: 3 }, + ], + }, + functions: { + found: 1, + hit: 1, + details: [{ line: 1, name: 'sum', hit: 3 }], + }, + }, + ]), + ).toStrictEqual<LCOVRecord>({ + title: '', + file: 'src/commands.ts', + branches: { + found: 2, + hit: 2, + details: [ + { line: 1, block: 1, branch: 0, taken: 2 }, + { line: 1, block: 1, branch: 1, taken: 1 }, + ], + }, + lines: { + found: 3, + hit: 3, + details: [ + { line: 1, hit: 2 }, + { line: 2, hit: 2 }, + { line: 3, hit: 3 }, + ], + }, + functions: { + found: 1, + hit: 1, + details: [{ line: 1, name: 'sum', hit: 3 }], + }, + }); + }); +}); + +describe('mergeLcovLineDetails', () => { + it('should sum number of times a line was hit', () => { + expect( + mergeLcovLineDetails([ + [ + { line: 1, hit: 1 }, + { line: 2, hit: 2 }, + ], + [ + { line: 1, hit: 2 }, + { line: 2, hit: 1 }, + ], + ]), + ).toStrictEqual<LinesDetails[]>([ + { line: 1, hit: 3 }, + { line: 2, hit: 3 }, + ]); + }); + + it('should include all unique lines', () => { + expect( + mergeLcovLineDetails([ + [ + { line: 1, hit: 1 }, + { line: 2, hit: 0 }, + { line: 4, hit: 2 }, + ], + [ + { line: 1, hit: 0 }, + { line: 2, hit: 1 }, + { line: 3, hit: 0 }, + ], + ]), + ).toStrictEqual<LinesDetails[]>([ + { line: 1, hit: 1 }, + { line: 2, hit: 1 }, + { line: 4, hit: 2 }, + { line: 3, hit: 0 }, + ]); + }); +}); + +describe('mergeLcovBranchDetails', () => { + it('should sum number of times a branch was taken', () => { + expect( + mergeLcovBranchesDetails([ + [ + { line: 1, block: 0, branch: 0, taken: 1 }, + { line: 1, block: 0, branch: 1, taken: 0 }, + ], + [ + { line: 1, block: 0, branch: 0, taken: 1 }, + { line: 1, block: 0, branch: 1, taken: 1 }, + ], + ]), + ).toStrictEqual<BranchesDetails[]>([ + { line: 1, block: 0, branch: 0, taken: 2 }, + { line: 1, block: 0, branch: 1, taken: 1 }, + ]); + }); + + it('should include all unique branches', () => { + expect( + mergeLcovBranchesDetails([ + [ + { line: 1, block: 0, branch: 0, taken: 1 }, + { line: 1, block: 0, branch: 1, taken: 0 }, + ], + [ + { line: 1, block: 0, branch: 0, taken: 1 }, + { line: 3, block: 0, branch: 0, taken: 0 }, + ], + ]), + ).toStrictEqual<BranchesDetails[]>([ + { line: 1, block: 0, branch: 0, taken: 2 }, + { line: 1, block: 0, branch: 1, taken: 0 }, + { line: 3, block: 0, branch: 0, taken: 0 }, + ]); + }); +}); + +describe('mergeLcovFunctionsDetails', () => { + it('should sum number of times a function was hit', () => { + expect( + mergeLcovFunctionsDetails([ + [ + { line: 1, name: 'sum', hit: 1 }, + { line: 5, name: 'mult', hit: 2 }, + ], + [ + { line: 1, name: 'sum', hit: 0 }, + { line: 5, name: 'mult', hit: 0 }, + ], + ]), + ).toStrictEqual<FunctionsDetails[]>([ + { line: 1, name: 'sum', hit: 1 }, + { line: 5, name: 'mult', hit: 2 }, + ]); + }); + + it('should include all unique functions', () => { + expect( + mergeLcovFunctionsDetails([ + [ + { line: 1, name: 'sum', hit: 1 }, + { line: 5, name: 'mult', hit: 2 }, + ], + [ + { line: 1, name: 'sum', hit: 3 }, + { line: 7, name: 'div', hit: 0 }, + ], + ]), + ).toStrictEqual<FunctionsDetails[]>([ + { line: 1, name: 'sum', hit: 4 }, + { line: 5, name: 'mult', hit: 2 }, + { line: 7, name: 'div', hit: 0 }, + ]); + }); +});