Skip to content

Commit

Permalink
Add changed line coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
tmonck committed Feb 12, 2025
1 parent 0ea9b32 commit e0a9237
Show file tree
Hide file tree
Showing 18 changed files with 193 additions and 45 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ jobs:
- run: npm run build
test:
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Test - Results All
uses: ./
with:
Expand Down Expand Up @@ -49,6 +52,7 @@ jobs:
results-path: ./files/success/*
coverage-path: ./files/success/test_coverage_opencover.xml
coverage-threshold: 44
changed-files-and-line-numbers: '[{"name":"Specifications\\BaseSpecification.cs","lineNumbers":[17,18,19]}]'
- name: Test - Coverage Cobertura
uses: ./
with:
Expand All @@ -58,6 +62,7 @@ jobs:
coverage-path: ./files/success/test_coverage_cobertura.xml
coverage-type: cobertura
coverage-threshold: 44
changed-files-and-line-numbers: '[{"name": "Specifications\\BaseSpecification.cs", "lineNumbers": [17,18,19]}]'
- name: Test - Show Failed Tests Only
uses: ./
with:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ Set to `false` or leave blank to show all the test results (recommended).
Set to `true` or leave blank to show the output of the tests. (recommended).
Set to `false` if there is too much output leading to truncation on the summary
<br/>Default: `true`

#### `change-files-and-line-numbers`
**Optional** - Array of changed files and lines numbers.
<br/>Examples: `[{"name":"Specifications\\BaseSpecification.cs","lineNumbers":[17,18,19]}]`
<br/>Default: `[]`

## Outputs

#### `tests-total`
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ inputs:
coverage-threshold:
description: 'Minimum allowed coverage (from 0.00 to 100.00)'
required: false
changed-files-and-line-numbers:
description: 'Array of changed files and lines numbers'
required: false
default: '[]'
comment-title:
description: 'Pull Request comment title'
required: false
Expand Down
90 changes: 74 additions & 16 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/coverage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CoverageType, CoverageParser, ICoverage, ChangedFileWithLineNumbers } from './data';
import { glob } from 'glob';
import { CoverageType, CoverageParser, ICoverage } from './data';
import { setFailed, setCoverageOutputs, log } from './utils';
import parseOpencover from './parsers/opencover';
import parseCobertura from './parsers/cobertura';
Expand All @@ -12,7 +12,8 @@ const parsers: { [K in CoverageType]: CoverageParser } = {
export const processTestCoverage = async (
coveragePath: string,
coverageType: CoverageType,
coverageThreshold: number
coverageThreshold: number,
changedFilesAndLineNumbers: ChangedFileWithLineNumbers[]
): Promise<ICoverage | null> => {
const filePaths = await glob(coveragePath, { nodir: true });

Expand All @@ -22,7 +23,7 @@ export const processTestCoverage = async (
}

const filePath = filePaths[0];
const coverage = await parsers[coverageType](filePath, coverageThreshold);
const coverage = await parsers[coverageType](coveragePath, coverageThreshold, changedFilesAndLineNumbers);

if (!coverage) {
log(`Failed parsing ${filePath}`);
Expand Down
3 changes: 2 additions & 1 deletion src/data/CoverageParser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChangedFileWithLineNumbers } from './IActionInputs';
import ICoverage from './ICoverage';

type CoverageParser = (filePath: string, threshold: number) => Promise<ICoverage | null>;
type CoverageParser = (filePath: string, threshold: number, changedFilesAndLineNumbers: ChangedFileWithLineNumbers[]) => Promise<ICoverage | null>;

export default CoverageParser;
6 changes: 6 additions & 0 deletions src/data/IActionInputs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import CoverageType from './CoverageType';

export type ChangedFileWithLineNumbers = {
name: string;
lineNumbers: number[];
};

export default interface IActionInputs {
token: string;
title: string;
Expand All @@ -9,6 +14,7 @@ export default interface IActionInputs {
coverageThreshold: number;
postNewComment: boolean;
allowFailedTests: boolean;
changedFilesAndLineNumbers: ChangedFileWithLineNumbers[];
showFailedTestsOnly: boolean;
showTestOutput: boolean;
}
3 changes: 3 additions & 0 deletions src/data/ICoverageData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export default interface ICoverageData {
totalCoverage: number;
changedLinesTotal: number;
changedLinesCovered: number;
changedLineCoverage: number;
linesTotal: number;
linesCovered: number;
lineCoverage: number;
Expand Down
2 changes: 2 additions & 0 deletions src/data/ICoverageFile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import ICoverageData from './ICoverageData';

export default interface ICoverageFile extends ICoverageData {
id: string;
name: string;
fullPath: string;
complexity: number;
linesToCover: number[];
}
2 changes: 1 addition & 1 deletion src/data/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type { default as IActionInputs } from './IActionInputs';
export type { default as IActionInputs, ChangedFileWithLineNumbers } from './IActionInputs';
export type { default as CoverageType } from './CoverageType';
export type { default as CoverageParser } from './CoverageParser';
export type { default as ICoverage } from './ICoverage';
Expand Down
2 changes: 2 additions & 0 deletions src/formatting/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const formatCoverageModule = (module: ICoverageModule): string => {
{ name: 'Line', align: 'center' },
{ name: 'Branch', align: 'center' },
{ name: 'Complexity', align: 'center' },
{ name: 'Changed Lines', align: 'center' },
{ name: 'Lines to Cover' }
],
module.files.map(file => [
Expand All @@ -69,6 +70,7 @@ const formatCoverageModule = (module: ICoverageModule): string => {
`${file.lineCoverage}%`,
`${file.branchCoverage}%`,
`${file.complexity}`,
`${file.changedLinesCovered} / ${file.changedLinesTotal} (${file.changedLineCoverage}%)`,
formatLinesToCover(file.linesToCover)
])
);
Expand Down
13 changes: 12 additions & 1 deletion src/formatting/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ICoverage, IResult } from '../data';
import { ICoverage, ICoverageFile, IResult } from '../data';
import { formatElapsedTime, getSectionLink, getStatusIcon } from './common';

export const formatHeaderMarkdown = (header: string): string => `## ${header}\n`;
Expand Down Expand Up @@ -35,4 +35,15 @@ export const formatCoverageMarkdown = (coverage: ICoverage, min: number): string
return `${title} ${info} ${status}\n${lines} ${branches}\n`;
};

export const formatChangedFileCoverageMarkdown = (files: ICoverageFile[]): string => {
let table = '| Filename | Lines Covered | Changed Lines Covered |\n'
table += '|----------|---------------|-----------------------|\n'
for (let file of files ) {
const { name, changedLineCoverage, changedLinesTotal, changedLinesCovered, linesCovered, linesTotal, lineCoverage } = file;
table += `| ${name} | ${linesCovered} / ${linesTotal} (${lineCoverage}%) | ${changedLinesCovered} / ${changedLinesTotal} (${changedLineCoverage}%) |\n`;
}

return `${table}\n`;
}

const getStatusText = (success: boolean) => (success ? '**passed**' : '**failed**');
13 changes: 11 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { processTestResults } from './results';
import { processTestCoverage } from './coverage';
import { getInputs, publishComment, setFailed, setSummary } from './utils';
import { formatCoverageMarkdown, formatResultMarkdown } from './formatting/markdown';
import { formatChangedFileCoverageMarkdown, formatCoverageMarkdown, formatResultMarkdown } from './formatting/markdown';
import { formatCoverageHtml, formatResultHtml, formatTitleHtml } from './formatting/html';

const run = async (): Promise<void> => {
Expand All @@ -15,6 +15,7 @@ const run = async (): Promise<void> => {
coverageThreshold,
postNewComment,
allowFailedTests,
changedFilesAndLineNumbers,
showFailedTestsOnly,
showTestOutput
} = getInputs();
Expand All @@ -27,11 +28,19 @@ const run = async (): Promise<void> => {
summary += formatResultHtml(testResult, showFailedTestsOnly, showTestOutput);

if (coveragePath) {
const testCoverage = await processTestCoverage(coveragePath, coverageType, coverageThreshold);
const testCoverage = await processTestCoverage(coveragePath, coverageType, coverageThreshold, changedFilesAndLineNumbers);
comment += testCoverage ? formatCoverageMarkdown(testCoverage, coverageThreshold) : '';
summary += testCoverage ? formatCoverageHtml(testCoverage) : '';
if (testCoverage) {
for(let myMod of testCoverage.modules) {
const changedFiles = myMod.files.filter(f => f.changedLinesTotal > 0);
const tempComment = formatChangedFileCoverageMarkdown(changedFiles);
await publishComment(token, `${myMod.name}'s Changed File Coverage`, tempComment, postNewComment);
}
}
}


await setSummary(summary);
await publishComment(token, title, comment, postNewComment);
} catch (error) {
Expand Down
36 changes: 28 additions & 8 deletions src/parsers/cobertura.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { CoverageParser, ICoverageData, ICoverageModule } from '../data';
import { CoverageParser, ICoverageData, ICoverageFile, ICoverageModule, ChangedFileWithLineNumbers } from '../data';
import { calculateCoverage, createCoverageModule, parseCoverage } from './common';

const parseCobertura: CoverageParser = async (filePath: string, threshold: number) =>
parseCoverage(filePath, threshold, parseSummary, parseModules);
const parseCobertura: CoverageParser = async (filePath: string, threshold: number, changedFilesAndLineNumbers: ChangedFileWithLineNumbers[]) =>
parseCoverage(filePath, threshold, changedFilesAndLineNumbers, parseSummary, parseModules);

const parseSummary = (file: any): ICoverageData => {
const parseSummary = (file: any, modules: ICoverageModule[]): ICoverageData => {
const summary = file.coverage['$'];
const totalCoverage = calculateCoverage(
Number(summary['lines-covered']) + Number(summary['branches-covered']),
Number(summary['lines-valid']) + Number(summary['branches-valid'])
);

const changedLinesTotal = Number(modules.reduce((summ, m) => summ + Number(m.files.reduce((summ2, f) => summ2 + Number(f.changedLinesTotal), 0)), 0))
const changedLinesCovered = Number(modules.reduce((summ, m) => summ + Number(m.files.reduce((summ2, f) => summ2 + Number(f.changedLinesCovered), 0)), 0))

return {
totalCoverage,
changedLinesTotal,
changedLinesCovered,
changedLineCoverage: calculateCoverage(changedLinesCovered, changedLinesTotal),
linesTotal: Number(summary['lines-valid']),
linesCovered: Number(summary['lines-covered']),
lineCoverage: calculateCoverage(summary['lines-covered'], summary['lines-valid']),
Expand All @@ -22,13 +28,14 @@ const parseSummary = (file: any): ICoverageData => {
};
};

const parseModules = (file: any, threshold: number): ICoverageModule[] => {
const parseModules = (file: any, threshold: number, changedFilesAndLineNumbers: ChangedFileWithLineNumbers[]): ICoverageModule[] => {
const fileFullDirPath = file.coverage.sources[0].source;
const modules = (file.coverage.packages[0].package ?? []) as any[];

return modules.map(module => {
const name = String(module['$'].name);
const classes = (module.classes[0].class ?? []) as any[];
const files = parseFiles(classes);
const files = parseFiles(classes, fileFullDirPath);
const complexity = Number(module['$'].complexity)

classes.forEach(c => {
Expand All @@ -39,14 +46,23 @@ const parseModules = (file: any, threshold: number): ICoverageModule[] => {
.filter(l => l['$']['condition-coverage'])
.map(l => branchRegex.exec(String(l['$']['condition-coverage']))?.[1].split('/') ?? []);

const coverableLines = lines.map(line => Number(line['$'].number));

if (file) {
const changedFile = changedFilesAndLineNumbers.find(f => (f.name === file.name) || (f.name === file.fullPath));
const changedLineNumbers = changedFile?.lineNumbers.filter(ln => coverableLines.includes(Number(ln))) || [];
const changedLines = lines.filter(l => changedLineNumbers.includes(Number(l['$'].number)));
file.linesTotal += Number(lines.length);
file.linesCovered += Number(lines.filter(l => Number(l['$'].hits) > 0).length);
file.branchesTotal += branchData.reduce((summ, branch) => summ + Number(branch[1]), 0);
file.branchesCovered += branchData.reduce((summ, branch) => summ + Number(branch[0]), 0);
file.linesToCover = file.linesToCover.concat(
lines.filter(line => !Number(line['$'].hits)).map(line => Number(line['$'].number))
);
const unCoveredChangedLines = changedLines?.filter(line => !Number(line['$'].hits)).map(line => Number(line['$'].number)) || [];
file.changedLinesTotal = changedLines.length;
file.changedLinesCovered = changedLines.length - unCoveredChangedLines.length;
file.changedLineCoverage = calculateCoverage(file.changedLinesCovered, changedLines.length);
file.complexity = Number(c['$'].complexity)
}
});
Expand All @@ -55,12 +71,16 @@ const parseModules = (file: any, threshold: number): ICoverageModule[] => {
});
};

const parseFiles = (classes: any[]) => {
const parseFiles = (classes: any[], fileDirPath: string) => {
const fileNames = [...new Set(classes.map(c => String(c['$'].filename)))];

return fileNames.map(file => ({
name: file,
fullPath: fileDirPath + file,
totalCoverage: 0,
changedLinesTotal: 0,
changedLinesCovered: 0,
changedLineCoverage: 0,
linesTotal: 0,
linesCovered: 0,
lineCoverage: 0,
Expand All @@ -69,7 +89,7 @@ const parseFiles = (classes: any[]) => {
branchCoverage: 0,
linesToCover: Array<number>(),
complexity: 0
}));
} as ICoverageFile));
};

export default parseCobertura;
11 changes: 6 additions & 5 deletions src/parsers/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ICoverage, ICoverageData, ICoverageFile, ICoverageModule } from '../data';
import { ICoverage, ICoverageData, ICoverageFile, ICoverageModule, ChangedFileWithLineNumbers } from '../data';
import { readXmlFile } from '../utils';

export const calculateCoverage = (covered: number, total: number): number => {
Expand Down Expand Up @@ -31,17 +31,18 @@ export const createCoverageModule = (
export const parseCoverage = async (
filePath: string,
threshold: number,
parseSummary: (file: any) => ICoverageData,
parseModules: (file: any, threshold: number) => ICoverageModule[]
changedFilesAndLineNumbers: ChangedFileWithLineNumbers[],
parseSummary: (file: any, modules: ICoverageModule[]) => ICoverageData,
parseModules: (file: any, threshold: number, changedFilesAndLineNumbers: ChangedFileWithLineNumbers[]) => ICoverageModule[]
): Promise<ICoverage | null> => {
const file = await readXmlFile(filePath);

if (!file) {
return null;
}

const summary = parseSummary(file);
const modules = parseModules(file, threshold);
const modules = parseModules(file, threshold, changedFilesAndLineNumbers);
const summary = parseSummary(file, modules);
const success = !threshold || summary.totalCoverage >= threshold;

return { success, ...summary, modules };
Expand Down
Loading

0 comments on commit e0a9237

Please sign in to comment.