Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog of the Pytest Coverage Comment

## [Pytest Coverage Comment 1.10.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.10.0)

**Release Date:** 2026-07-05

#### Changes
Comment thread
MishaKav marked this conversation as resolved.

- add support for parsing the JSON coverage report from `coverage json` via the new `pytest-json-coverage-path` input, including branch coverage and partial-branch arrows (`line->target`, `line->exit`) that match `coverage report -m` (#286)

## [Pytest Coverage Comment 1.9.0](https://github.com/MishaKav/pytest-coverage-comment/tree/v1.9.0)

**Release Date:** 2026-07-04
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ The codebase is a TypeScript GitHub Action with the following key components:

- **src/parse.ts**: Parses pytest text coverage output (e.g., from `pytest --cov`)
- **src/parseXml.ts**: Parses XML coverage reports (e.g., from `pytest --cov-report=xml`)
- **src/parseJson.ts**: Parses `coverage json` reports; returns the same `XmlCoverageReport` shape as parseXml and reuses `toHtml` + `DataFromXml`
- **src/junitXml.ts**: Parses JUnit XML test results for test statistics

### Supporting Modules
Expand Down Expand Up @@ -99,6 +100,10 @@ The codebase is a TypeScript GitHub Action with the following key components:
- Supports filtering to show only changed files in the current commit
- Can skip files with 100% coverage from XML reports
- Handles both absolute and relative file paths for coverage inputs
- Coverage parser precedence (index.ts, cli.ts, multiFiles.ts): JSON > XML > TXT. multiFiles picks the parser by file extension (`.json`/`.xml`, else txt). JSON and XML expose `coverage` as a `TotalLine` object; TXT exposes it as a string
- coverage.py JSON schema (from `coverage json`): top-level key is `totals` (not `summary`); per-file `summary.missing_lines` is a **count** while `file.missing_lines` is an **array**; `missing_branches` arcs are `[from, to]` pairs where a negative `to` means an exit arc (`from->exit`)
- The JSON "Missing" column matches `coverage report -m`: a branch arc whose destination is an already-missing line is omitted (the line shows on its own). The XML parser predates this and shows a redundant `from->dest` arrow in that case — a known minor divergence, not fixed to keep the diff minimal
- `formatCoverPercent` (exported from parseXml.ts) is the shared rounding helper (caps 99.x→99, floors 0.x→1); JSON uses coverage.py's precomputed `percent_covered` so it never needs `computeCoverPercent`
- xml2js `parseString` is used synchronously via callback pattern (not async)
- `@actions/github` has ESM/CJS compatibility issues in test context — mock it with `vi.mock()` in tests
- `@actions/core` v3+ and `@actions/github` v9+ are pure ESM — incompatible with ncc's webpack CJS bundling. Must stay on `@actions/core` v2.x and `@actions/github` v8.x until ncc supports ESM or the project switches bundlers
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ jobs:
| `github-token` | ✓ | `${{github.token}}` | GitHub token for API access to create/update comments |
| `pytest-coverage-path` | | `./pytest-coverage.txt` | Path to pytest text coverage output (from `--cov-report=term-missing`) |
| `pytest-xml-coverage-path` | | | Path to XML coverage report (from `--cov-report=xml:coverage.xml`) |
| `pytest-json-coverage-path`| | | Path to JSON coverage report (from `coverage json`) |
| `junitxml-path` | | | Path to JUnit XML file for test statistics (passed/failed/skipped) |
| `issue-number` | | | Pull request number to comment on (required for workflow_dispatch/workflow_run events) |

Expand Down Expand Up @@ -251,6 +252,28 @@ jobs:

</details>

### Coverage from JSON

<details>
<summary>Using coverage.json instead of text output</summary>

The JSON report's coverage percentages match `coverage report` exactly (statements and branches combined), and it is the most robust format to parse.

```yaml
- name: Generate JSON coverage
run: |
pytest --cov=src tests/
coverage json -o coverage.json

- name: Coverage comment
uses: MishaKav/pytest-coverage-comment@v1
with:
pytest-json-coverage-path: ./coverage.json
junitxml-path: ./pytest.xml
```

</details>

### Monorepo Support

<details>
Expand Down
1 change: 1 addition & 0 deletions __tests__/junitXml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const baseOptions: Options = {
pathPrefix: '',
covFile: '',
covXmlFile: '',
covJsonFile: '',
xmlFile: '',
title: 'Coverage Report',
badgeTitle: 'Coverage',
Expand Down
1 change: 1 addition & 0 deletions __tests__/multiFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const baseOptions: Options = {
pathPrefix: '',
covFile: '',
covXmlFile: '',
covJsonFile: '',
xmlFile: '',
title: 'Coverage Report',
badgeTitle: 'Coverage',
Expand Down
1 change: 1 addition & 0 deletions __tests__/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const baseOptions: Options = {
pathPrefix: '',
covFile: '',
covXmlFile: '',
covJsonFile: '',
xmlFile: '',
title: 'Coverage Report',
badgeTitle: 'Coverage',
Expand Down
190 changes: 190 additions & 0 deletions __tests__/parseJson.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { expect, test, describe, beforeEach } from 'vitest';
import * as path from 'path';
import { getCoverageJsonReport, exportedForTesting } from '../src/parseJson';
import { spyCore } from './setup';
import type { Options } from '../src/types';

const { isValidCoverageContent, collapseRanges, getMissing, getTotalCoverage } =
exportedForTesting;

type MissingArg = Parameters<typeof getMissing>[0];
type TotalsArg = Parameters<typeof getTotalCoverage>[0];

const dataPath = path.resolve(__dirname, '..', 'data');

const baseOptions: Options = {
token: 'token_123',
repository: 'MishaKav/pytest-coverage-comment',
commit: 'abc123',
prefix: '',
pathPrefix: '',
covFile: '',
covXmlFile: '',
covJsonFile: '',
xmlFile: '',
title: 'Coverage Report',
badgeTitle: 'Coverage',
hideBadge: false,
hideReport: false,
createNewComment: false,
hideComment: false,
hideEmoji: false,
xmlSkipCovered: false,
reportOnlyChangedFiles: false,
removeLinkFromBadge: false,
removeLinksToFiles: false,
removeLinksToLines: false,
textInsteadBadge: false,
defaultBranch: 'main',
xmlTitle: '',
multipleFiles: [],
repoUrl: 'https://github.com/MishaKav/pytest-coverage-comment',
};

describe('getCoverageJsonReport', () => {
beforeEach(() => {
spyCore.error.mockClear();
spyCore.warning.mockClear();
spyCore.info.mockClear();
});

test('should return null for empty covJsonFile', () => {
const result = getCoverageJsonReport(baseOptions);
expect(result).toBeNull();
});

test('should return null for non-existent file', () => {
const options = { ...baseOptions, covJsonFile: '/nonexistent/coverage.json' };
const result = getCoverageJsonReport(options);
expect(result).toBeNull();
});

test('should warn and return null for malformed json', () => {
const covJsonFile = path.join(dataPath, 'coverage_1.xml'); // not json
const options = { ...baseOptions, covJsonFile };
const result = getCoverageJsonReport(options);

expect(result).toBeNull();
expect(spyCore.warning).toHaveBeenCalled();
});

test('should parse coverage_1.json (no branches) successfully', () => {
const covJsonFile = path.join(dataPath, 'coverage_1.json');
const options = { ...baseOptions, covJsonFile };
const result = getCoverageJsonReport(options);

expect(result).not.toBeNull();
expect(result!.html).toContain('img.shields.io/badge');
expect(result!.coverage).not.toBeNull();
expect(result!.coverage!.name).toBe('TOTAL');
expect(result!.coverage!.cover).toBe('88%');
// foo.py is 80% with missing lines 5 and 12
expect(result!.html).toContain('<td>80%</td>');
expect(result!.html).toContain('>5</a>');
expect(result!.html).toContain('>12</a>');
// no branch columns for a statement-only report
expect(result!.html).not.toContain('<th>Branch</th>');
});

test('should parse coverage_2.json with branch coverage', () => {
const covJsonFile = path.join(dataPath, 'coverage_2.json');
const options = { ...baseOptions, covJsonFile };
const result = getCoverageJsonReport(options);

expect(result).not.toBeNull();
expect(result!.coverage!.cover).toBe('82%');
expect(result!.coverage!.branch).toBe('10');
expect(result!.coverage!.brpart).toBe('3');
expect(result!.html).toContain('<th>Branch</th><th>BrPart</th>');
// branch arcs from getMissing are wired into the missing column
expect(result!.html).toContain('>2->exit</a>');
});

test('should skip covered files when xmlSkipCovered is true', () => {
const covJsonFile = path.join(dataPath, 'coverage_1.json');
const options = { ...baseOptions, covJsonFile, xmlSkipCovered: true };
const result = getCoverageJsonReport(options);

expect(result).not.toBeNull();
// bar.py is 100% covered and should be excluded from the table
expect(result!.html).not.toContain('bar.py');
expect(result!.html).toContain('foo.py');
});
});

describe('parseJson internals', () => {
test('isValidCoverageContent', () => {
expect(isValidCoverageContent(null)).toBe(false);
expect(isValidCoverageContent({ files: {} } as never)).toBe(false);
expect(isValidCoverageContent({ files: {}, totals: {} } as never)).toBe(
true,
);
});

test('collapseRanges groups consecutive lines', () => {
expect(collapseRanges([4, 10, 11, 12])).toEqual([
{ sort: 4, text: '4' },
{ sort: 10, text: '10-12' },
]);
expect(collapseRanges([])).toEqual([]);
});

test('getMissing renders lines, exit arcs, and normal arcs sorted by line', () => {
const asArg = (file: {
missing_lines: number[];
missing_branches: number[][];
}): MissingArg => file as unknown as MissingArg;

expect(
getMissing(asArg({ missing_lines: [5, 14], missing_branches: [[2, 5]] })),
).toEqual(['5', '14']);
expect(
getMissing(asArg({ missing_lines: [], missing_branches: [[2, -1]] })),
).toEqual(['2->exit']);
expect(
getMissing(asArg({ missing_lines: [], missing_branches: [[2, 4]] })),
).toEqual(['2->4']);
expect(
getMissing(asArg({ missing_lines: [6], missing_branches: [[2, 4]] })),
).toEqual(['2->4', '6']);
});

test('getTotalCoverage maps totals to a TotalLine', () => {
expect(
getTotalCoverage({
num_statements: 16,
missing_lines: 2,
percent_covered: 87.5,
} as TotalsArg),
).toEqual({ name: 'TOTAL', stmts: 16, miss: 2, cover: '88%' });

expect(
getTotalCoverage({
num_statements: 18,
missing_lines: 2,
percent_covered: 82.14,
num_branches: 10,
missing_branches: 3,
} as TotalsArg),
).toEqual({
name: 'TOTAL',
stmts: 18,
miss: 2,
cover: '82%',
branch: '10',
brpart: '3',
});
});

test('getTotalCoverage returns null for a non-finite percentage', () => {
spyCore.warning.mockClear();
expect(
getTotalCoverage({
num_statements: 0,
missing_lines: 0,
percent_covered: NaN,
} as TotalsArg),
).toBeNull();
expect(spyCore.warning).toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions __tests__/parseXml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const baseOptions: Options = {
pathPrefix: '',
covFile: '',
covXmlFile: '',
covJsonFile: '',
xmlFile: '',
title: 'Coverage Report',
badgeTitle: 'Coverage',
Expand Down
7 changes: 6 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ inputs:
default: ''
required: false

pytest-json-coverage-path:
description: 'Path to JSON coverage report (from coverage json)'
default: ''
required: false

coverage-path-prefix:
description: 'Prefix to add to file paths in coverage report links'
default: ''
Expand Down Expand Up @@ -95,7 +100,7 @@ inputs:
description: >
Generate single comment with multiple coverage reports (useful for monorepos).
Format: One report per line as "Title, coverage-path, junit-path".
Coverage path can be TXT (from --cov-report=term-missing) or XML (from --cov-report=xml).
Coverage path can be TXT (from --cov-report=term-missing), XML (from --cov-report=xml), or JSON (from coverage json).
Examples: "Backend API, ./backend/coverage.txt, ./backend/junit.xml"
"Frontend Tests, ./frontend/coverage.xml, ./frontend/junit.xml"
default: ''
Expand Down
44 changes: 44 additions & 0 deletions data/coverage_1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"meta": {
"format": 3,
"version": "7.15.0",
"branch_coverage": false,
"show_contexts": false
},
"files": {
"src/foo.py": {
"executed_lines": [1, 2, 3, 4, 6, 7, 8, 9],
"summary": {
"covered_lines": 8,
"num_statements": 10,
"percent_covered": 80.0,
"percent_covered_display": "80",
"missing_lines": 2,
"excluded_lines": 0
},
"missing_lines": [5, 12],
"excluded_lines": []
},
"src/bar.py": {
"executed_lines": [1, 2, 3, 4, 5, 6],
"summary": {
"covered_lines": 6,
"num_statements": 6,
"percent_covered": 100.0,
"percent_covered_display": "100",
"missing_lines": 0,
"excluded_lines": 0
},
"missing_lines": [],
"excluded_lines": []
}
},
"totals": {
"covered_lines": 14,
"num_statements": 16,
"percent_covered": 87.5,
"percent_covered_display": "88",
"missing_lines": 2,
"excluded_lines": 0
}
}
Loading