diff --git a/packages/plugin-coverage/mocks/duplicate-record-lcov.info b/packages/plugin-coverage/mocks/duplicate-record-lcov.info new file mode 100644 index 000000000..b7dc2ee45 --- /dev/null +++ b/packages/plugin-coverage/mocks/duplicate-record-lcov.info @@ -0,0 +1,33 @@ +TN: +SF:src\lib\utils.ts +FN:2,formatReportScore +FN:6,calcDuration +FNF:2 +FNH:1 +FNDA:1,formatReportScore +FNDA:6,calcDuration +DA:1,1 +DA:2,1 +DA:3,0 +DA:4,0 +DA:5,1 +DA:6,1 +DA:7,1 +DA:8,1 +DA:9,1 +DA:10,1 +LF:10 +LH:8 +BRDA:1,0,0,6 +BRDA:1,1,0,5 +BRDA:2,4,0,1 +BRDA:4,5,0,17 +BRDA:5,6,0,4 +BRDA:6,7,0,13 +BRDA:6,10,0,1 +BRDA:7,11,0,3 +BRDA:10,12,0,12 +BRDA:10,13,0,1 +BRF:10 +BRH:9 +end_of_record diff --git a/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.int.test.ts.snap b/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.int.test.ts.snap index e062ef1c2..8447688b0 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.int.test.ts.snap +++ b/packages/plugin-coverage/src/lib/runner/lcov/__snapshots__/lcov-runner.int.test.ts.snap @@ -274,3 +274,191 @@ exports[`lcovResultsToAuditOutputs > should correctly merge all lines for covera }, ] `; + +exports[`lcovResultsToAuditOutputs > should correctly merge duplicate LCOV records from multiple files 1`] = ` +[ + { + "details": { + "trees": [ + { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 1, + }, + }, + ], + "name": "src", + "values": { + "coverage": 1, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 1, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 1, + }, + }, + ], + "name": ".", + "values": { + "coverage": 1, + }, + }, + "title": "Branch coverage", + "type": "coverage", + }, + ], + }, + "displayValue": "100 %", + "score": 1, + "slug": "branch-coverage", + "value": 100, + }, + { + "details": { + "trees": [ + { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 1, + }, + }, + ], + "name": "src", + "values": { + "coverage": 1, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 1, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 1, + }, + }, + ], + "name": ".", + "values": { + "coverage": 1, + }, + }, + "title": "Function coverage", + "type": "coverage", + }, + ], + }, + "displayValue": "100 %", + "score": 1, + "slug": "function-coverage", + "value": 100, + }, + { + "details": { + "trees": [ + { + "root": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "utils.ts", + "values": { + "coverage": 1, + "missing": [], + }, + }, + ], + "name": "lib", + "values": { + "coverage": 1, + }, + }, + ], + "name": "src", + "values": { + "coverage": 1, + }, + }, + ], + "name": "cli", + "values": { + "coverage": 1, + }, + }, + ], + "name": "packages", + "values": { + "coverage": 1, + }, + }, + ], + "name": ".", + "values": { + "coverage": 1, + }, + }, + "title": "Line coverage", + "type": "coverage", + }, + ], + }, + "displayValue": "100 %", + "score": 1, + "slug": "line-coverage", + "value": 100, + }, +] +`; diff --git a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.int.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.int.test.ts index a151c3534..0478e1043 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.int.test.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.int.test.ts @@ -52,4 +52,37 @@ describe('lcovResultsToAuditOutputs', () => { ); expect(osAgnosticAuditOutputs(results)).toMatchSnapshot(); }); + + it('should correctly merge duplicate LCOV records from multiple files', async () => { + const results = await lcovResultsToAuditOutputs( + [ + { + resultsPath: path.join( + fileURLToPath(path.dirname(import.meta.url)), + '..', + '..', + '..', + '..', + 'mocks', + 'single-record-lcov.info', + ), + pathToProject: 'packages/cli', + }, + { + resultsPath: path.join( + fileURLToPath(path.dirname(import.meta.url)), + '..', + '..', + '..', + '..', + 'mocks', + 'duplicate-record-lcov.info', + ), + pathToProject: 'packages/cli', + }, + ], + ['branch', 'function', 'line'], + ); + expect(osAgnosticAuditOutputs(results)).toMatchSnapshot(); + }); }); 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 14da90d7e..b943b04f0 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 @@ -1,8 +1,17 @@ import { vol } from 'memfs'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { ui } from '@code-pushup/utils'; -import { parseLcovFiles } from './lcov-runner.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getGitRoot, ui } from '@code-pushup/utils'; +import type { CoverageResult, CoverageType } from '../../config.js'; +import { lcovResultsToAuditOutputs, parseLcovFiles } from './lcov-runner.js'; + +vi.mock('@code-pushup/utils', async () => { + const actual = await vi.importActual('@code-pushup/utils'); + return { + ...actual, + getGitRoot: vi.fn(), + }; +}); describe('parseLcovFiles', () => { const UTILS_REPORT = ` @@ -43,6 +52,29 @@ LH:3 BRF:0 BRH:0 end_of_record +`; + + const MULTI_FILE_REPORT = ` +TN: +SF:${path.join('file1', 'test.ts')} +FNF:1 +FNH:1 +DA:1,1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record +TN: +SF:${path.join('file2', 'test.ts')} +FNF:0 +FNH:0 +DA:1,0 +LF:1 +LH:0 +BRF:1 +BRH:0 +end_of_record `; beforeEach(() => { @@ -51,6 +83,7 @@ end_of_record [path.join('integration-tests', 'lcov.info')]: UTILS_REPORT, // file name value under SF used in tests [path.join('unit-tests', 'lcov.info')]: CONSTANTS_REPORT, // file name value under SF used in tests [path.join('pytest', 'lcov.info')]: PYTEST_REPORT, + [path.join('multi', 'lcov.info')]: MULTI_FILE_REPORT, 'lcov.info': '', // empty report file }, 'coverage', @@ -136,4 +169,234 @@ end_of_record }), ]); }); + + it('should sanitize hit values to not exceed found values when invalid stats are encountered', async () => { + const invalidReport = ` +TN: +SF:${path.join('invalid', 'file.ts')} +FNF:2 +FNH:3 +DA:1,1 +DA:2,1 +LF:2 +LH:3 +BRF:1 +BRH:2 +end_of_record +`; + + vol.fromJSON( + { + [path.join('invalid', 'lcov.info')]: invalidReport, + }, + 'coverage', + ); + + const result = await parseLcovFiles([ + path.join('coverage', 'invalid', 'lcov.info'), + ]); + + expect(result[0]?.functions.hit).toBe(2); + expect(result[0]?.lines.hit).toBe(2); + expect(result[0]?.branches.hit).toBe(1); + }); + + it('should handle multiple files with different coverage types', async () => { + const result = await parseLcovFiles([ + path.join('coverage', 'multi', 'lcov.info'), + ]); + + expect(result).toHaveLength(2); + expect(result[0]?.file).toBe(path.join('file1', 'test.ts')); + expect(result[1]?.file).toBe(path.join('file2', 'test.ts')); + expect(result[0]?.functions.hit).toBe(1); + expect(result[1]?.functions.hit).toBe(0); + }); + + it('should handle edge case with no branches or functions', async () => { + const edgeCaseReport = ` +TN: +SF:${path.join('edge', 'case.ts')} +FNF:0 +FNH:0 +DA:1,1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record +`; + + vol.fromJSON( + { + [path.join('edge', 'lcov.info')]: edgeCaseReport, + }, + 'coverage', + ); + + const result = await parseLcovFiles([ + path.join('coverage', 'edge', 'lcov.info'), + ]); + + expect(result[0]?.functions.hit).toBe(0); + expect(result[0]?.functions.found).toBe(0); + expect(result[0]?.branches.hit).toBe(0); + expect(result[0]?.branches.found).toBe(0); + expect(result[0]?.lines.hit).toBe(1); + expect(result[0]?.lines.found).toBe(1); + }); +}); + +describe('lcovResultsToAuditOutputs', () => { + const mockResults: CoverageResult[] = [ + { + resultsPath: path.join('coverage', 'test', 'lcov.info'), + pathToProject: 'packages/cli', + }, + ]; + + const mockCoverageTypes: CoverageType[] = ['function', 'branch', 'line']; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getGitRoot).mockResolvedValue('/mock/git/root'); + + const testReport = ` +TN: +SF:${path.join('src', 'test.ts')} +FNF:1 +FNH:1 +DA:1,1 +LF:1 +LH:1 +BRF:1 +BRH:1 +end_of_record +`; + + vol.fromJSON( + { + [path.join('test', 'lcov.info')]: testReport, + }, + 'coverage', + ); + }); + + it('should return audit outputs for all coverage types', async () => { + const result = await lcovResultsToAuditOutputs( + mockResults, + mockCoverageTypes, + ); + + expect(result).toHaveLength(3); + expect(result[0]).toHaveProperty('slug', 'function-coverage'); + expect(result[1]).toHaveProperty('slug', 'branch-coverage'); + expect(result[2]).toHaveProperty('slug', 'line-coverage'); + }); + + it('should handle single coverage type', async () => { + const result = await lcovResultsToAuditOutputs(mockResults, ['function']); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('slug', 'function-coverage'); + }); + + it('should handle empty coverage types array', async () => { + const result = await lcovResultsToAuditOutputs(mockResults, []); + + expect(result).toHaveLength(0); + }); + + it('should handle getGitRoot failure gracefully', async () => { + vi.mocked(getGitRoot).mockRejectedValue(new Error('Git root not found')); + + await expect( + lcovResultsToAuditOutputs(mockResults, mockCoverageTypes), + ).rejects.toThrow('Git root not found'); + }); + + it('should handle multiple results with different project paths', async () => { + const multiResults: CoverageResult[] = [ + { + resultsPath: path.join('coverage', 'test', 'lcov.info'), + pathToProject: 'packages/cli', + }, + { + resultsPath: path.join('coverage', 'test2', 'lcov.info'), + pathToProject: 'packages/utils', + }, + ]; + + const testReport2 = ` +TN: +SF:${path.join('src', 'utils.ts')} +FNF:2 +FNH:1 +DA:1,1 +DA:2,0 +LF:2 +LH:1 +BRF:1 +BRH:0 +end_of_record +`; + + vol.fromJSON( + { + [path.join('test2', 'lcov.info')]: testReport2, + }, + 'coverage', + ); + + const result = await lcovResultsToAuditOutputs(multiResults, ['function']); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('slug', 'function-coverage'); + }); + + it('should handle string results path', async () => { + const stringResults: CoverageResult[] = [ + path.join('coverage', 'test', 'lcov.info'), + ]; + + const result = await lcovResultsToAuditOutputs(stringResults, ['line']); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('slug', 'line-coverage'); + }); + + it('should handle mixed results format', async () => { + const mixedResults: CoverageResult[] = [ + path.join('coverage', 'test', 'lcov.info'), + { + resultsPath: path.join('coverage', 'test2', 'lcov.info'), + pathToProject: 'packages/utils', + }, + ]; + + const testReport2 = ` +TN: +SF:${path.join('src', 'utils.ts')} +FNF:1 +FNH:1 +DA:1,1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record +`; + + vol.fromJSON( + { + [path.join('test2', 'lcov.info')]: testReport2, + }, + 'coverage', + ); + + const result = await lcovResultsToAuditOutputs(mixedResults, ['function']); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('slug', 'function-coverage'); + }); }); 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 index 8ea4f4ee9..6bdf1fa01 100644 --- 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 @@ -90,6 +90,80 @@ describe('mergeLcovResults', () => { UNIQUE_REPORT, ]); }); + + it('should return records unchanged when no duplicates exist', () => { + const records = [ + { + title: '', + file: 'src/file1.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { found: 1, hit: 1, details: [{ line: 1, hit: 1 }] }, + functions: { found: 0, hit: 0, details: [] }, + }, + { + title: '', + file: 'src/file2.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { found: 1, hit: 0, details: [{ line: 1, hit: 0 }] }, + functions: { found: 0, hit: 0, details: [] }, + }, + ]; + + expect(mergeLcovResults(records)).toStrictEqual(records); + }); + + it('should handle empty records array', () => { + expect(mergeLcovResults([])).toStrictEqual([]); + }); + + it('should handle single record', () => { + const singleRecord = { + title: '', + file: 'src/single.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { found: 1, hit: 1, details: [{ line: 1, hit: 1 }] }, + functions: { found: 0, hit: 0, details: [] }, + }; + + expect(mergeLcovResults([singleRecord])).toStrictEqual([singleRecord]); + }); + + it('should handle duplicates with different line counts', () => { + const records = [ + { + title: '', + file: 'src/file.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { + found: 2, + hit: 1, + details: [ + { line: 1, hit: 1 }, + { line: 2, hit: 0 }, + ], + }, + functions: { found: 0, hit: 0, details: [] }, + }, + { + title: '', + file: 'src/file.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { + found: 2, + hit: 1, + details: [ + { line: 1, hit: 1 }, + { line: 2, hit: 0 }, + ], + }, + functions: { found: 0, hit: 0, details: [] }, + }, + ]; + + const result = mergeLcovResults(records); + expect(result).toHaveLength(1); + expect(result[0]?.file).toBe('src/file.ts'); + }); }); describe('mergeDuplicateLcovRecords', () => { @@ -176,6 +250,82 @@ describe('mergeDuplicateLcovRecords', () => { }, }); }); + + it('should handle records with zero hit counts', () => { + const records = [ + { + title: '', + file: 'src/file.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { + found: 2, + hit: 0, + details: [ + { line: 1, hit: 0 }, + { line: 2, hit: 0 }, + ], + }, + functions: { + found: 1, + hit: 0, + details: [{ line: 1, name: 'func', hit: 0 }], + }, + }, + { + title: '', + file: 'src/file.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { + found: 2, + hit: 0, + details: [ + { line: 1, hit: 0 }, + { line: 2, hit: 0 }, + ], + }, + functions: { + found: 1, + hit: 0, + details: [{ line: 1, name: 'func', hit: 0 }], + }, + }, + ]; + + const result = mergeDuplicateLcovRecords(records); + expect(result.lines.hit).toBe(0); + expect(result.functions.hit).toBe(0); + expect(result.branches.hit).toBe(0); + }); + + it('should handle records with null function hit values', () => { + const records = [ + { + title: '', + file: 'src/file.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { found: 1, hit: 1, details: [{ line: 1, hit: 1 }] }, + functions: { + found: 1, + hit: 0, + details: [{ line: 1, name: 'func', hit: undefined }], + }, + }, + { + title: '', + file: 'src/file.ts', + branches: { found: 0, hit: 0, details: [] }, + lines: { found: 1, hit: 1, details: [{ line: 1, hit: 1 }] }, + functions: { + found: 1, + hit: 0, + details: [{ line: 1, name: 'func', hit: 2 }], + }, + }, + ]; + + const result = mergeDuplicateLcovRecords(records); + expect(result.functions.hit).toBe(1); // Only the second record has hit > 0 + }); }); describe('mergeLcovLineDetails', () => { @@ -218,6 +368,24 @@ describe('mergeLcovLineDetails', () => { { line: 3, hit: 0 }, ]); }); + + it('should handle empty details arrays', () => { + expect(mergeLcovLineDetails([])).toStrictEqual([]); + expect(mergeLcovLineDetails([[]])).toStrictEqual([]); + expect(mergeLcovLineDetails([[], []])).toStrictEqual([]); + }); + + it('should handle single line details', () => { + expect( + mergeLcovLineDetails([[{ line: 1, hit: 5 }], [{ line: 1, hit: 3 }]]), + ).toStrictEqual([{ line: 1, hit: 8 }]); + }); + + it('should handle negative hit values', () => { + expect( + mergeLcovLineDetails([[{ line: 1, hit: -1 }], [{ line: 1, hit: 2 }]]), + ).toStrictEqual([{ line: 1, hit: 1 }]); + }); }); describe('mergeLcovBranchDetails', () => { @@ -257,6 +425,45 @@ describe('mergeLcovBranchDetails', () => { { line: 3, block: 0, branch: 0, taken: 0 }, ]); }); + + it('should handle empty details arrays', () => { + expect(mergeLcovBranchesDetails([])).toStrictEqual([]); + expect(mergeLcovBranchesDetails([[]])).toStrictEqual([]); + expect(mergeLcovBranchesDetails([[], []])).toStrictEqual([]); + }); + + it('should handle complex branch structures', () => { + expect( + mergeLcovBranchesDetails([ + [ + { line: 1, block: 1, branch: 0, taken: 1 }, + { line: 1, block: 1, branch: 1, taken: 0 }, + { line: 2, block: 2, branch: 0, taken: 1 }, + ], + [ + { line: 1, block: 1, branch: 0, taken: 1 }, + { line: 1, block: 1, branch: 1, taken: 1 }, + { line: 3, block: 3, branch: 0, taken: 0 }, + ], + ]), + ).toStrictEqual([ + { line: 1, block: 1, branch: 0, taken: 2 }, + { line: 1, block: 1, branch: 1, taken: 1 }, + { line: 2, block: 2, branch: 0, taken: 1 }, + { line: 3, block: 3, branch: 0, taken: 0 }, + ]); + }); + + it('should handle negative taken values', () => { + expect( + mergeLcovBranchesDetails([ + [{ line: 1, block: 0, branch: 0, taken: -1 }], + [{ line: 1, block: 0, branch: 0, taken: 2 }], + ]), + ).toStrictEqual([ + { line: 1, block: 0, branch: 0, taken: 1 }, + ]); + }); }); describe('mergeLcovFunctionsDetails', () => { @@ -296,4 +503,55 @@ describe('mergeLcovFunctionsDetails', () => { { line: 7, name: 'div', hit: 0 }, ]); }); + + it('should handle empty details arrays', () => { + expect(mergeLcovFunctionsDetails([])).toStrictEqual([]); + expect(mergeLcovFunctionsDetails([[]])).toStrictEqual([]); + expect(mergeLcovFunctionsDetails([[], []])).toStrictEqual([]); + }); + + it('should handle undefined hit values', () => { + expect( + mergeLcovFunctionsDetails([ + [{ line: 1, name: 'func', hit: undefined }], + [{ line: 1, name: 'func', hit: 2 }], + ]), + ).toStrictEqual([{ line: 1, name: 'func', hit: 2 }]); + }); + + it('should handle multiple undefined hit values', () => { + expect( + mergeLcovFunctionsDetails([ + [{ line: 1, name: 'func', hit: undefined }], + [{ line: 1, name: 'func', hit: undefined }], + ]), + ).toStrictEqual([{ line: 1, name: 'func', hit: 0 }]); + }); + + it('should handle functions with same name but different lines', () => { + expect( + mergeLcovFunctionsDetails([ + [ + { line: 1, name: 'func', hit: 1 }, + { line: 5, name: 'func', hit: 2 }, + ], + [ + { line: 1, name: 'func', hit: 3 }, + { line: 5, name: 'func', hit: 1 }, + ], + ]), + ).toStrictEqual([ + { line: 1, name: 'func', hit: 4 }, + { line: 5, name: 'func', hit: 3 }, + ]); + }); + + it('should handle negative hit values', () => { + expect( + mergeLcovFunctionsDetails([ + [{ line: 1, name: 'func', hit: -1 }], + [{ line: 1, name: 'func', hit: 3 }], + ]), + ).toStrictEqual([{ line: 1, name: 'func', hit: 2 }]); + }); }); diff --git a/packages/plugin-coverage/vitest.int.config.ts b/packages/plugin-coverage/vitest.int.config.ts index a105197ee..1386d8f90 100644 --- a/packages/plugin-coverage/vitest.int.config.ts +++ b/packages/plugin-coverage/vitest.int.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ coverage: { reporter: ['text', 'lcov'], reportsDirectory: '../../coverage/plugin-coverage/int-tests', - exclude: ['mocks/**', '**/types.ts'], + exclude: ['mocks/**', '**/types.ts', '**/vitest.*.config.ts'], }, environment: 'node', include: ['src/**/*.int.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], diff --git a/packages/plugin-coverage/vitest.unit.config.ts b/packages/plugin-coverage/vitest.unit.config.ts index e86d3f2e5..e0bd6113e 100644 --- a/packages/plugin-coverage/vitest.unit.config.ts +++ b/packages/plugin-coverage/vitest.unit.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ coverage: { reporter: ['text', 'lcov'], reportsDirectory: '../../coverage/plugin-coverage/unit-tests', - exclude: ['mocks/**', '**/types.ts'], + exclude: ['mocks/**', '**/types.ts', '**/vitest.*.config.ts'], }, environment: 'node', include: ['src/**/*.unit.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],