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 },
+    ]);
+  });
+});